Adopting iOS Dark Mode
At a glance
| Item |
Summary |
| Purpose |
Contrasts fixed-color UI with dynamic system colors, materials, trait-aware drawing, and scoped style overrides. |
| App architecture |
A storyboard catalog pairs Before1... and After2... controllers; each screen is an independent migration example rather than a shared feature pipeline. |
| Main patterns |
Before/after example matrix, trait-context capture, background work with main-thread handoff, and scoped appearance override. |
| Project style |
One UIKit target with parallel controllers and paired light/dark assets. |
Project structure
AdoptingDarkMode/
├── AppDelegate.swift
├── Before1MoreViewController.swift
├── After2MoreViewController.swift
├── Before1SlowDrawingViewController.swift
├── After2SlowDrawingViewController.swift
├── AlwaysDarkViewController.swift
├── Base.lproj/Main.storyboard
└── Assets.xcassets/
Structure observations
- Paired filenames make adoption changes directly comparable.
- Dynamic named colors/images live in the asset catalog; programmatic system colors/materials remain in the after controller.
- Only the slow-drawing example owns asynchronous state and trait-change regeneration.
Overall architecture
flowchart LR
Storyboard["Storyboard catalog"] --> Before["Before1 examples"]
Storyboard --> After["After2 examples"]
Storyboard --> Forced["AlwaysDarkViewController"]
Before --> Fixed["Fixed colors and light blur"]
After --> Dynamic["System colors, named assets, materials"]
After --> Traits["UITraitCollection"]
Traits --> Drawing["Trait-aware background renderer"]
Forced --> Override["overrideUserInterfaceStyle"]
Reference code
AdoptingDarkMode/After2SlowDrawingViewController.swift:45 — the adopted path captures the current traits before leaving the main thread and resolves dynamic colors under that context.
let colors: [UIColor] = [.systemBackground,
.secondarySystemBackground,
.gray,
UIColor(named: "LightAndDarkHeaderColor")!]
let traitCollection = self.traitCollection
DispatchQueue.global(qos: .userInitiated).async {
var image: UIImage?
traitCollection.performAsCurrent {
image = self.createImage(size, colors)
}
DispatchQueue.main.async { self.resultImageView.image = image }
}
Interpretation
The sample is organized for learning, not reuse. Architecture is the comparison: the after screens move appearance selection to semantic assets/UIKit traits and explicitly carry those traits into off-main rendering.
Ownership and state
classDiagram
Storyboard *-- UIViewController : creates examples
After2SlowDrawingViewController o-- UIImageView : outlet
After2SlowDrawingViewController o-- UIActivityIndicatorView : outlet
After2SlowDrawingViewController --> UITraitCollection : captures value
BackgroundQueue --> UIImage : creates
MainQueue --> UIImageView : assigns
AlwaysDarkViewController --> UIUserInterfaceStyle : overrides subtree
Ownership evidence
AdoptingDarkMode/After2SlowDrawingViewController.swift:15 — the drawing screen keeps only storyboard outlets; render inputs/results are local to each run.
@IBOutlet var resultImageView: UIImageView!
@IBOutlet var activityIndicator: UIActivityIndicatorView!
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
performSlowDrawing()
}
| Owner |
Object or state |
Relationship |
Mutation authority |
| Storyboard/view hierarchy |
Example controllers and outlets |
Creates/retains |
UIKit lifecycle |
| Slow-drawing controller |
Current visible image/spinner state |
Controls through outlets |
Main-thread callbacks and trait changes |
| Background closure |
Rendered image and captured inputs |
Temporary values |
Worker queue until handoff |
| Always-dark controller |
Appearance override for its subtree |
Sets inherited trait override |
Its initializers |
Class and protocol design
| Type |
Responsibility |
Depends on |
| Before/after “More” controllers |
Construct comparable labels, images, blur, and vibrancy |
UIKit views/effects/assets |
| Before/after slow-drawing controllers |
Contrast fixed versus trait-aware off-main rendering |
Dispatch queues, image renderer |
AlwaysDarkViewController |
Force one controller subtree to dark appearance |
overrideUserInterfaceStyle |
No local protocol or cross-screen service exists; each controller is an isolated API demonstration.
Access control
| Symbol |
Access |
Verified effect |
Likely rationale |
| Example controllers/methods/outlets |
implicit internal |
Storyboard and target code can access them. |
Educational app with no library surface. |
| Rendering inputs/results |
lexical locals |
Other objects cannot mutate a render in progress. |
Per-operation state requires no stored exposure. |
No private / fileprivate members |
verified absence |
There is no explicit lexical boundary. |
Brevity; sample isolation provides practical separation. |
Reference code
AdoptingDarkMode/AlwaysDarkViewController.swift:10 — style ownership is scoped to the controller, but the type itself remains target-internal.
class AlwaysDarkViewController: UIViewController {
override init(nibName: String?, bundle: Bundle?) {
super.init(nibName: nibName, bundle: bundle)
overrideUserInterfaceStyle = .dark
}
required init?(coder: NSCoder) {
super.init(coder: coder)
overrideUserInterfaceStyle = .dark
}
}
Logic ownership and placement
| Logic |
Owning type or file |
Placement rationale |
| Migration comparison |
Paired before/after controllers |
Makes code-level differences visible. |
| Semantic assets |
Asset catalog |
UIKit resolves appearance automatically. |
| Off-main drawing and trait capture |
After slow-drawing controller |
One operation needs both screen traits and output outlet. |
| Redraw on appearance change |
traitCollectionDidChange |
Framework trait lifecycle boundary. |
| Forced dark scope |
AlwaysDarkViewController |
Controller subtree owns the exception. |
Design patterns
| Pattern |
Source evidence |
Purpose or tradeoff |
| Before/after example matrix |
AdoptingDarkMode/Before1MoreViewController.swift:11 and AdoptingDarkMode/After2MoreViewController.swift:11 |
Optimizes comparison, not production deduplication. |
| Trait-context capture |
AdoptingDarkMode/After2SlowDrawingViewController.swift:53 |
Resolves dynamic colors correctly off the main thread. |
| Main-thread handoff |
AdoptingDarkMode/After2SlowDrawingViewController.swift:66 |
Keeps expensive drawing off-main and UIKit updates on-main. |
| Scoped appearance override |
AdoptingDarkMode/AlwaysDarkViewController.swift:21 |
Forces one subtree without changing global appearance. |
Naming conventions
Before1 and After2 prefixes encode teaching order and migration state.
SlowDrawing names the concurrency case; More collects ordinary view/material examples.
AlwaysDarkViewController names the behavior rather than a domain screen.
Architecture takeaways
- Treat this source as a migration comparison, not a reusable multi-screen app design.
- Let semantic colors, named assets, and materials resolve appearance automatically.
- Capture a trait collection before background rendering and install results on the main queue.
- Scope forced appearance to the narrowest controller subtree.
Source map
| Source file |
Relevant symbols |
AdoptingDarkMode/Before1MoreViewController.swift |
Fixed-color/material baseline |
AdoptingDarkMode/After2MoreViewController.swift |
Semantic color/material adoption |
AdoptingDarkMode/Before1SlowDrawingViewController.swift |
Non-trait-aware drawing baseline |
AdoptingDarkMode/After2SlowDrawingViewController.swift |
Trait-aware background rendering |
AdoptingDarkMode/AlwaysDarkViewController.swift |
Scoped style override |