A lightweight, high-performance rich-text library for all Apple platforms — UIKit, AppKit, and SwiftUI (including watchOS).
Note: This fork is reimplemented in Swift 6.0 with strict concurrency. Version 2.0 uses renamed public APIs such as
TextLabelView,TextLabel, andTextLabel.Attachment.
- ⚡️ High performance text layout and rendering via CoreText
- 🌊 Streaming-friendly: new text that only changed near its end typesets just the paragraphs that changed
- 📎 Native view embedding via attachments
- 🔗 Clickable links support
- ✏️ Text selection with copy/paste
- 🔗 One selection across several labels, such as the cells of a table
- 🎨 Custom per-line drawing callbacks
- 🎞️ Animated text changes, such as streamed text fading in, through the optional
LitextAnimationproduct - 📐 Auto layout integration (experimental)
- 🖥️ SwiftUI support on all platforms, including watchOS
Litext supports iOS 15, macOS 12, Mac Catalyst 15, tvOS 15, visionOS 1 and watchOS 8, the oldest systems current toolchains deploy to. It requires Swift 6.2 (Xcode 26) or later.
| Platform | Minimum Version | TextLabelView (UIKit/AppKit) | TextLabel (SwiftUI) |
|---|---|---|---|
| iOS | 15.0+ | ✅ | ✅ |
| macOS | 12.0+ | ✅ | ✅ |
| tvOS | 15.0+ | ✅ | ✅ |
| visionOS | 1.0+ | ✅ | ✅ |
| Mac Catalyst | 15.0+ | ✅ | ✅ |
| watchOS | 8.0+ | — | ✅ |
Add Litext as a dependency in your Package.swift file:
dependencies: [
.package(url: "https://github.com/Helixform/Litext.git", branch: "main")
]Or in Xcode: File → Add Package Dependencies and enter the repository URL.
import Litext
let label = TextLabelView()
view.addSubview(label)
let attributedString = NSMutableAttributedString(
string: "Hello, Litext!",
attributes: [
.font: PlatformFont.systemFont(ofSize: 16),
.foregroundColor: PlatformColor.label
]
)
label.attributedText = attributedString
// Measure without Auto Layout, as with UILabel: the text wrapped at 300 points.
let size = label.sizeThatFits(CGSize(width: 300, height: 0))TextLabel works on all platforms, including watchOS:
import Litext
import SwiftUI
struct ContentView: View {
var body: some View {
TextLabel("Hello, Litext!")
.selectable()
.onTapLink { url in
UIApplication.shared.open(url)
}
}
}You can also initialise with an NSAttributedString or AttributedString:
TextLabel(attributedString: myNSAttributedString)
TextLabel(attributedString: myAttributedString)let mutable = NSMutableAttributedString(string: "Visit GitHub")
mutable.addAttribute(.link, value: URL(string: "https://github.com")!, range: NSRange(location: 6, length: 6))
label.attributedText = mutable
// UIKit/AppKit — implement TextLabelViewDelegate
label.delegate = self
func textLabelView(
_ textLabelView: TextLabelView,
didTapHighlightRegion region: TextLabel.HighlightRegion,
at location: CGPoint
) {
if let url = region.linkURL {
UIApplication.shared.open(url)
}
}
// SwiftUI — use the modifier
TextLabel(attributedString: mutable)
.onTapLink { url in
UIApplication.shared.open(url)
}To find what lies under a point yourself, for a long-press preview or a hover card, ask the label. Points are in the label's coordinates:
if let region = label.highlightRegion(at: point), let url = region.linkURL {
showPreview(for: url)
}
let index = label.characterIndex(at: point)
// Style the highlight shown while a link is pressed.
label.linkHighlightColor = UIColor.systemBlue.withAlphaComponent(0.2)
label.linkHighlightCornerRadius = 6// Enable selection
label.isSelectable = true
// Optional: an explicit color uses Litext's custom selection drawing.
label.selectionBackgroundColor = UIColor.systemBlue.withAlphaComponent(0.2)
// Access selected text
let text = label.selectedPlainText()
let attributed = label.selectedAttributedText()
// Programmatic selection
label.selectionRange = NSRange(location: 0, length: 5)
label.selectWord(at: 7) // as a double-click does
label.selectLine(at: 7) // as a triple-click does
label.selectAll()
label.clearSelection()
// SwiftUI
TextLabel("Some selectable text")
.selectable()On iOS 17 and later, selection uses UIKit's UITextSelectionDisplayInteraction and UITextLoupeSession for native highlights, handles and the magnifier while holding or dragging a selection. A selection group shares one pair of handles across its labels. Leave selectionBackgroundColor as nil to use the native display; an explicit color preserves Litext's custom drawing and its exact alpha for the whole group. Earlier iOS versions and other platforms retain their existing selection display.
Labels that share a TextSelectionGroup share one selection, so a selection can run from one label into the next, for example across the cells of a table. Copy, Look Up, Translate and Share act on all of it.
let group = TextSelectionGroup()
// The order is the reading order: list a table row by row.
group.labels = cells
// Put a tab between cells of a row and a line break between rows.
group.separator = { previous, next in row(of: previous) == row(of: next) ? "\t" : "\n" }
group.delegate = self
// TextSelectionGroupDelegate
func textSelectionGroupDidChangeSelection(_ group: TextSelectionGroup) {
print(group.selectedPlainText() ?? "")
}
// Add commands to the menu (iOS 16, Mac Catalyst 16 or later).
func textSelectionGroup(
_ group: TextSelectionGroup,
editMenuForSuggestedActions suggestedActions: [UIMenuElement],
) -> UIMenu? {
UIMenu(children: suggestedActions + [copyAsMarkdownAction])
}The group holds its labels weakly; keep the labels alive as usual. group.selectedSegments lists the selected range in each label.
Use TextLabel.Attachment to embed any view inline in the text:
// UIKit / AppKit
let attachment = TextLabel.Attachment()
attachment.view = myCustomView // UIView or NSView
attachment.size = myCustomView.intrinsicContentSize
// watchOS (SwiftUI view instead)
let attachment = TextLabel.Attachment()
attachment.swiftUIView = AnyView(MyCustomView())
attachment.size = CGSize(width: 100, height: 50)
// Insert attachment into attributed string
let attachmentString = attachment.attributedString()
// Sit it on the baseline instead of a tenth of its height below it.
attachment.descent = 0
// SwiftUI — handle taps on attachments
TextLabel(attributedString: text)
.onTapAttachment { attachment in
print("Tapped", attachment)
}After changing size or descent of an attachment already on screen, call reloadTextLayout() on its label.
layoutLines reports each laid-out line: its character range, its box, and where its baseline starts. Use it to count lines or align with a baseline. label.textLayout gives the full TextLabel.Layout for anything else, such as rects(for:).
let lineCount = label.layoutLines.count
if let first = label.layoutLines.first {
let rect = label.viewRect(fromLayoutRect: first.rect)
print(first.stringRange, rect)
}Each line also carries its typeset CTLine as line, so an effect can read glyph runs and positions without typesetting the text again.
Line and run geometry is in CoreText layout space, with the origin at the bottom left; convert it with viewRect(fromLayoutRect:). Each read of layoutLines or layoutRuns(matching:) builds a new array, so read them once per layout rather than on every frame.
let drawingAction = TextLabel.LineDrawingAction { context, line, origin in
// Custom drawing for each line
context.setStrokeColor(UIColor.red.cgColor)
context.move(to: CGPoint(x: origin.x, y: origin.y - 2))
context.addLine(to: CGPoint(x: origin.x + 100, y: origin.y - 2))
context.strokePath()
}
attributedString.addAttribute(
.litextLineDrawingAction,
value: drawingAction,
range: fullRange
)Line drawing actions run after the text. To draw behind a line's glyphs, such as a pill behind inline code, or to draw the glyphs yourself, give the label a line renderer. The label hands it to every layout it builds, so it keeps working in a subclass that makes its own layout, LTXAnimatableLabel included:
final class PillRenderer: TextLabel.LineRenderer {
override func drawBackground(of line: CTLine, at index: Int, in context: CGContext, layout: TextLabel.Layout) {
// CoreText space; the text position is where the line's baseline starts.
}
}
label.lineRenderer = PillRenderer()LitextAnimation is a separate product that animates text as it changes, for example a model's reply fading in as it streams. Its LTXAnimatableLabel is a TextLabelView subclass. The library provides the base layer: a display link that runs while something animates, a diff of each change aligned to grapheme clusters, redrawing of the lines in flight only, and a policy for cell reuse. It ships two effects, a staggered fade-in for streamed text and a rolling transition for counters and titles; other effects live in your app, behind the LTXTextAnimator protocol.
dependencies: [
.product(name: "LitextAnimation", package: "Litext"),
]LTXFadeInAnimator fades in each new character, or each word, one after another, and LTXFadeUpAnimator adds a small rise. Both honour reduced motion with a short, plain fade:
import LitextAnimation
let label = LTXAnimatableLabel()
label.animator = LTXFadeInAnimator()
label.attributedText = reply // each later assignment fades in only what was added
// Tune it through its configuration.
let fade = LTXFadeInAnimator(configuration: .init(duration: 0.4, granularity: .word, curve: .easeInOut))
fade.configuration.stagger = LTXStaggerSchedule(interval: 0.03, maxTotalDelay: 0.3)The fade stays cheap while a reply streams: opacity moves in steps (32 by default), so a frame redraws only the units that changed step, and a line in flight is drawn in stretches of glyphs that share an opacity. A subclass can override its LTXTextAnimator members and call super.
LTXNumericTransitionAnimator rolls short text from one value to the next: glyphs the two texts share slide into place, new ones spring in and old ones roll away, and a number that went down rolls the other way. Most changes of a counter replace the whole text, which the default policy does not animate, so give the label the animator's policy too:
counter.animator = LTXNumericTransitionAnimator()
counter.animationPolicy = LTXNumericTransitionAnimator.policy
counter.attributedText = NSAttributedString(string: "1,024") // rolls to each new valueIts springs, stagger and direction are in its configuration, built from LTXDampedSpring. Both animators are open.
For an effect of your own, conform to LTXTextAnimator. An animator needs five members. This one fades in each line that new text lands on:
import LitextAnimation
final class LineFadeAnimator: LTXTextAnimator {
private(set) var animatingRange: NSRange?
private var start: CFTimeInterval = 0
private let duration: CFTimeInterval = 0.3
func animateChange(_ context: LTXAnimationContext, at time: CFTimeInterval) {
animatingRange = context.change.insertedRange
start = time
}
// Once per frame: name what to redraw, and return false when done.
func advance(to time: CFTimeInterval, invalidation: LTXInvalidationContext) -> Bool {
guard let range = animatingRange else { return false }
invalidation.invalidateCharacters(in: range)
if time - start >= duration { animatingRange = nil }
return animatingRange != nil
}
// Lines touching `animatingRange` come here, in CoreText space with the text position set.
func draw(_ line: LTXAnimatedLine, in context: CGContext, at time: CFTimeInterval) -> Bool {
context.setAlpha(min(max((time - start) / duration, 0), 1))
line.draw(in: context) // as the label draws it at rest, renderer background included
return true // false lets the label draw the line as usual
}
func finish() {
animatingRange = nil
}
}
label.animator = LineFadeAnimator()Lines outside animatingRange are drawn exactly as TextLabelView draws them, and an idle label has no display link and no extra subviews. For effects that draw past the line boxes, or draw glyphs on their way out, implement overdrawInsets, additionalContentBounds and drawAdditionalContent(in:at:).
Reuse. In a reused cell, set animationIdentity before the text. A new identity shows its text at once; the same identity keeps animating what arrives:
cell.label.animationIdentity = message.id
cell.label.attributedText = message.renderedPolicy. animationPolicy decides for each change. The default animates when the label is in a window, animations are enabled (not inside performWithoutAnimation), the identity is unchanged, the change is not a whole replacement, and it adds text (or arrives while an animation is still running). Use LTXClosureAnimationPolicy for your own rule, setAttributedText(_:animated: false) to skip animation once, and finishAnimations() to jump to the final state.
Reduced motion. Every LTXAnimationContext carries prefersReducedMotion, so the animator can tone its effect down. When the setting turns on mid-animation, the label finishes the animations in flight.
LitextAnimation works on iOS, macOS, Mac Catalyst, tvOS and visionOS. On watchOS the product builds but is empty, since there is no TextLabelView. The catalog app shows streaming text, a rolling numeric transition, cell reuse and the animation policy and controls on their own pages.
On watchOS, TextLabelView (the UIView/NSView subclass) is not available. Use TextLabel instead — it renders via an off-screen CGContext and displays the result as a SwiftUI Image.
import Litext
import SwiftUI
struct WatchContentView: View {
var body: some View {
TextLabel(attributedString: styledText)
}
var styledText: NSAttributedString {
let s = NSMutableAttributedString(string: "Hello from Watch!")
s.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: 14), range: NSRange(location: 0, length: 17))
return s
}
}For inline attachments on watchOS, provide a SwiftUI view via swiftUIView instead of view.
LitextCatalog, in the LitextCatalog folder and opened through Litext.xcworkspace, is one app that shows every part of Litext: basics, typography, international text, interaction, attachments, layout, lists and performance, and animation. Each page explains what it shows, runs a live demo with controls for the knobs it is about, and has the code behind it. It runs on iOS, iPadOS, macOS (AppKit), Mac Catalyst, tvOS and visionOS, with a watchOS companion.
Launch arguments open a page directly, which is handy for screenshots and UI tests:
| Argument | Effect |
|---|---|
-page <id> |
Opens a page, such as -page layout.geometry or -page animation.streaming. |
-demo streaming|numeric|reuse |
Older names for the three animation pages. |
-slowMotion YES |
Starts the animation pages at a tenth of the speed. |
-effect none|fade|fadeUp |
The streaming page's starting effect. |
-autoScroll YES |
Starts the cell reuse page scrolling. |
This project is licensed under the MIT License — see the LICENSE file for details.


