Display text with a custom layout
At a glance
| Item |
Summary |
| Purpose |
Demonstrates circular line fragments, two-column flow, exclusion paths, and custom ellipsis glyphs. |
| App architecture |
One app-owned NSTextStorage feeds feature-specific TextKit stacks owned by three view-controller subclasses. |
| Main patterns |
Shared model, strategy by subclass, UIKit delegate adapters, and retained observation tokens. |
| Project style |
Storyboard tab interface; shared behavior in a base controller and complex glyph logic in a same-type extension file. |
Project structure
TextLayout/
├── AppDelegate.swift
├── BaseViewController.swift
├── CircleTextContainer.swift
├── CircleTextViewController.swift
├── CircleTextViewController+Ellipsis.swift
├── ExclusivePathViewController.swift
├── TwoColumnsViewController.swift
└── Base.lproj/Main.storyboard
Structure observations
BaseViewController contains only behavior shared by all text demonstrations: access to shared storage and keyboard-inset handling.
- The
+Ellipsis file keeps NSLayoutManagerDelegate glyph substitution separate from the circle screen’s lifecycle setup.
- Each feature controller constructs its own layout manager, text container, and text view instead of sharing presentation objects.
Overall architecture
flowchart LR
Bundle["Sample.rtf"] --> App["AppDelegate.textStorage"]
App --> Base["BaseViewController"]
Base --> Circle["CircleTextViewController"]
Base --> Columns["TwoColumnsViewController"]
Base --> Exclusion["ExclusivePathViewController"]
Circle --> CircleContainer["CircleTextContainer"]
Circle --> GlyphDelegate["NSLayoutManagerDelegate extension"]
Columns --> TwoContainers["Two NSTextContainers"]
Exclusion --> Path["Movable exclusion path"]
CircleContainer --> TextKit["TextKit layout"]
GlyphDelegate --> TextKit
TwoContainers --> TextKit
Path --> TextKit
Reference code
TextLayout/CircleTextViewController.swift:18 — the circle feature builds a complete TextKit presentation stack around the shared storage.
let textContainer = CircleTextContainer(size: .zero)
textContainer.widthTracksTextView = true
let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
textView = UITextView(frame: CGRect.zero, textContainer: textContainer)
Interpretation
The shared value is document content, not view state. Each tab applies a different layout strategy by attaching a separate layout manager and containers to the same NSTextStorage, so edits can be reflected across demonstrations.
Ownership and state
classDiagram
AppDelegate *-- NSTextStorage : creates shared instance
BaseViewController o-- NSTextStorage : lazy reference
BaseViewController *-- AnyCancellable : keyboard subscription
BaseViewController <|-- CircleTextViewController
BaseViewController <|-- TwoColumnsViewController
BaseViewController <|-- ExclusivePathViewController
CircleTextViewController *-- NSLayoutManager : creates
NSLayoutManager *-- CircleTextContainer : retains
CircleTextViewController o-- NSRange : glyph state
ExclusivePathViewController *-- NSKeyValueObservation : retains tokens
ExclusivePathViewController *-- UIBezierPath : exclusion geometry
Ownership evidence
TextLayout/AppDelegate.swift:13 — the app delegate lazily creates the single storage object from the bundled RTF.
lazy var textStorage: NSTextStorage = {
guard let fileURL = Bundle.main.url(forResource: "Sample", withExtension: "rtf") else {
fatalError("Failed to find Sample.rtf in the app bundle.")
}
let options = [NSAttributedString.DocumentReadingOptionKey.documentType:
NSAttributedString.DocumentType.rtf]
guard let textStorage = try? NSTextStorage(url: fileURL, options: options,
documentAttributes: nil) else {
fatalError("Failed to create a text storage from \(fileURL).")
}
return textStorage
}()
| Owner |
State |
Relationship |
Mutation authority |
AppDelegate |
Shared NSTextStorage |
Creates and retains |
Text views through their attached layout managers |
| Each feature controller |
Text view, layout manager, containers |
Creates presentation graph |
Its lifecycle/setup methods |
CircleTextViewController |
Ellipsis and flexible-space glyph ranges |
Retains transient substitution state |
Circle/ellipsis extension methods |
ExclusivePathViewController |
KVO tokens, drag origin, path |
Retains interaction state |
KVO closures and pan action |
Class and protocol design
| Type |
Responsibility |
Design note |
BaseViewController |
Shared storage access and keyboard inset subscription |
Template superclass for the three demonstrations |
CircleTextContainer |
Return circular line-fragment rectangles |
Focused NSTextContainer strategy subclass |
CircleTextViewController |
Compose circular TextKit stack and coordinate glyph substitution |
Framework delegate conformances live in extensions |
TwoColumnsViewController |
Flow one layout manager through two containers |
Demonstrates one model and sequential containers |
ExclusivePathViewController |
Keep an exclusion path aligned with a draggable image |
Owns observation and coordinate conversion |
There is no app-defined protocol. Protocol-oriented behavior comes from narrow UIKit conformances (UITextViewDelegate, UITabBarControllerDelegate, and NSLayoutManagerDelegate) attached to the type that owns the related state.
Access control
| Symbol |
Access |
Verified effect |
Rationale |
keyboardSubscriptions |
private |
Only the base controller can replace or cancel the subscription. |
Lifecycle resource is an implementation detail. |
| Exclusion-path observations and geometry |
private |
Pan/KVO invariants stay inside ExclusivePathViewController. |
No other type needs to mutate synchronization state. |
| Circle glyph ranges and helper methods |
implicit internal |
The separate +Ellipsis.swift extension can access them. |
File-splitting prevents private while keeping the API target-local. |
layoutManager(_:shouldGenerateGlyphs:...) |
explicit public |
Callable beyond the module if the containing type were visible. |
Broader than this internal app type requires; it is not a reusable-library boundary. |
| App and controller types |
implicit internal |
Available only within the sample target. |
The project exports no framework API. |
Reference code
TextLayout/ExclusivePathViewController.swift:13 — observation lifetimes and path geometry are deliberately controller-private.
private var kvoContentOffset: NSKeyValueObservation?
private var kvoTextViewPosition: NSKeyValueObservation?
private var panInitialImageCenter = CGPoint()
private lazy var circlePath: UIBezierPath = {
UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 100, height: 100))
}()
Logic ownership and placement
| Logic |
Owner |
Why it belongs there |
| Load shared RTF content |
AppDelegate |
Creates one model before feature screens request it. |
| Keyboard avoidance |
BaseViewController |
Identical lifecycle policy for all text screens. |
| Line-fragment geometry |
CircleTextContainer |
TextKit asks the container for available line rectangles. |
| Glyph replacement/restoration |
Circle controller extension |
Requires circle screen’s ranges and layout manager delegate callbacks. |
| Exclusion-path synchronization |
ExclusivePathViewController |
Couples image position, scrolling, and container coordinates. |
Design patterns
| Pattern |
Source evidence |
Purpose or tradeoff |
| Shared TextKit model |
TextLayout/AppDelegate.swift:15 |
One document backs multiple layouts; app delegate becomes a small service locator. |
| Strategy by subclass |
TextLayout/CircleTextContainer.swift:10 |
Overrides one TextKit geometry decision without changing controllers. |
| Delegate extension |
TextLayout/CircleTextViewController+Ellipsis.swift:79 |
Isolates framework callback-heavy glyph behavior. |
| Observation token ownership |
TextLayout/ExclusivePathViewController.swift:77 |
Retaining KVO tokens ties observation to controller lifetime. |
Naming conventions
- Screen types use outcome names:
CircleTextViewController, TwoColumnsViewController, and ExclusivePathViewController.
- TextKit graph objects keep framework vocabulary:
textStorage, layoutManager, and textContainer.
trigger...IfNeeded and restore...IfNeeded communicate guarded, idempotent glyph transitions.
- The
+Ellipsis suffix identifies a capability extension rather than another owner.
Architecture takeaways
- Share content storage when several layouts must reflect the same editable document, but keep presentation graphs independent.
- Put layout mathematics in the TextKit subclass or delegate callback UIKit already queries.
- Retain observation/subscription tokens beside the UI state they synchronize and cancel them at lifecycle boundaries.
- Treat cross-file internal visibility as a file-organization tradeoff, not as a public API decision.
Source map
| Source file |
Relevant symbols |
TextLayout/AppDelegate.swift |
Shared NSTextStorage owner |
TextLayout/BaseViewController.swift |
Storage access and keyboard subscription |
TextLayout/CircleTextContainer.swift |
Circular line-fragment strategy |
TextLayout/CircleTextViewController.swift |
Circle stack and delegate lifecycle |
TextLayout/CircleTextViewController+Ellipsis.swift |
Glyph substitution |
TextLayout/ExclusivePathViewController.swift |
Exclusion path and observations |
TextLayout/TwoColumnsViewController.swift |
Two-container flow |