
Uikit App Modernization
- 331 installs
- 263 repo stars
- Updated June 9, 2026
- superagents-lab/xcode27-skills
Helps with ai & agent building tasks.
About
uikit-app-modernization is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- uikit-app-modernization
- AI & Agent Building
- AI-coding skill
Uikit App Modernization by the numbers
- 331 all-time installs (skills.sh)
- +51 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #2,202 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/superagents-lab/xcode27-skills --skill uikit-app-modernizationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 331 |
|---|---|
| repo stars | ★ 263 |
| Last updated | June 9, 2026 |
| Repository | superagents-lab/xcode27-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
UIKit App Modernization Skill
Purpose
Modernize UIKit apps to behave correctly on modern iOS by:
- Eliminating references to legacy shared-state APIs
- Migrating from application lifecycle to scene lifecycle
- Supporting dynamic scene sizing and multi-window environments
Scope
This skill performs specific, targeted modernizations in both Swift and Objective-C codebases:
- Replace legacy shared-state APIs with context-appropriate modern APIs
- Migrate to scene-based lifecycle
- Update apps to support a resizable user interface by removing usage of:
- main screen (
UIScreen.mainScreen,UIScreen.main) - interface orientation (
interfaceOrientation) - assumptions of symmetric safe areas (
safeAreaLayoutGuide,safeAreaInsets)
Core Principles
1. Closest to consumer — Prefer information nearest the point of use (e.g., view's trait collection over window's). 2. Always apply a replacement when the target API is present. A TODO alone is a failure. An empty diff for a file containing the target API is also a failure. If the file contains the target deprecated API and a concrete replacement is feasible under any pattern in the active task's reference file, apply it. Only skip when the target API appears exclusively inside dead code (#if 0/#endif). When uncertain between two valid replacements, pick the one that best fits the user's request rather than producing an empty diff. Never silently skip a file: if you are unwilling to apply a change, talk to the user about possible options — never produce no output for it. Do not get stuck weighing edge cases on simple files; when the substitution is obvious, apply it and move on. 3. TODOs must be actionable. Every TODO you do leave must state (a) why the change is needed, (b) what the correct replacement would look like, and (c) any lifecycle or threading concerns. Place the TODO on its own line above the unchanged code — never inline. A vague TODO ("fix this later") is worse than no TODO; it consumes review attention without telling the next reader anything they couldn't infer. 4. Don't add a redundant TODO when an existing annotation already covers the migration. If the call site already has a #pragma clang diagnostic ignored paired with a bug-report reference, an existing // TODO, or a deprecation comment that points at the migration, do not add another one. Only add a new TODO when it provides additional migration guidance not present in the existing annotation. 5. Ask the user before making a risky code change; fall back to a TODO only when interactive guidance is unavailable. When a replacement risks breaking callers or changing observable behavior (e.g., changing a method signature in a header that other modules import; substituting width > height for orientation when left-vs-right matters), the first move is to ask the user how to proceed. Only when the skill is running non-interactively, or when the user explicitly declines to provide guidance, drop a TODO and move on. This does not apply to standard, drop-in safe replacements specified by the active task's reference file — those must be applied per Core Principle 2. 6. Honor explicit user instructions; otherwise apply the defaults from the task reference file. When the user asks for a specific approach — a particular attribute, parameter name, parameter position, trait source, or fallback behavior — use that exactly. Don't silently substitute what you consider the modern equivalent. When the user is general ("modernize this app", "fix UIScreen.main usages"), apply the defaults from the active task's reference file. 7. Never replace dynamic values with literals — Always keep replacements dynamic. 8. Preserve control flow — Prefer drop-in replacements that maintain the original code structure. Only add guard/early-return patterns when a direct substitution does not work. When editing code around control flow (`if`/`else`, `switch`/`case`/`default`, `do`/`catch`), verify that the branching structure is preserved after your edit. Never remove a branch (`} else {`, `default:`, `catch`) unless the user explicitly asks for it. A diff that collapses an `if`/`else` into sequential execution is a critical bug — both branches will execute unconditionally. 9. Stay in scope — no opportunistic cleanup. Only modify lines containing the target deprecated API for the active task. Do NOT also fix other deprecation that happens to live nearby. Do NOT trim trailing whitespace, reformat blank lines, or "clean up" surrounding formatting. Even if you see an obvious modernization opportunity on an adjacent line, leave it alone — each task is independent and out-of-scope edits convert a successful in-scope change into a warning. 10. Extract repeated expressions — When the same replacement value is used multiple times in a scope, extract it into a named local variable. 11. Never walk global scene/window state — Never use UIApplication.shared, UIDevice.current, UIScreen.main, or other shared objects as a replacement. If no local object is available, modify the method to accept a new parameter and deprecate the old method. 12. Complete patterns — atomic, never partial — Every multi-part pattern requires ALL parts applied together as a single atomic unit. Deprecate-and-forward requires deprecation + new overload + forwarding — never just an inline replacement when the pattern calls for method extraction. When the active task requires both an API replacement AND a reactive update (e.g., trait change observation), these form a single atomic change — never apply one without the other. Downgrading the deprecate-and-forward pattern to an inline reference to a shared object is an error — it silently breaks the migration story by removing the deprecated bridge that callers rely on to find the new API. If you cannot complete all four parts (new overload with the appropriate parameter name/type/position, old method delegates with shared state (e.g. UITraitCollection.current, UIScreen.main), old method marked deprecated with the appropriate attribute, deprecated wrapper kept in place), do not apply a partial change — either complete the full pattern or skip with an explicit reason. 13. Never remove the old method when adding a new overload. When applying deprecate-and-forward, the old method must remain in the file as the deprecated wrapper that forwards to the new overload via .current. Deleting the old method (even if it appears unused in the diff) removes the deprecation signal from the codebase and silently drops the migration bridge. This applies to ObjC methods, Swift methods, Swift initializers, computed properties, and protocol-extension methods. If you find yourself removing a method as part of adding a new overload, STOP — you should be keeping it with a deprecation attribute, not deleting it. 14. Preserve unrelated guards and fallbacks. When removing a UIScreen.mainScreen reference, change ONLY that reference. Do not simultaneously delete respondsToSelector: checks, nil-screen guards, if (screen != nil) defenses, version checks (#available, @available), or any other defensive logic that wraps the call site — unless the user explicitly asks for it. Each guard exists for an independent reason (selector availability across SDK versions, nil-window safety, feature flags); the modernization touches only the screen-derived value, not the surrounding control flow. 15. Apply the deprecation at the lowest method that touches the deprecated API. When several callers funnel into one helper that actually reads the deprecated shared state, put the deprecate-and-forward on the helper, not on every public caller. Forcing every public caller to grow a traitCollection: parameter when the helper is the only site that needs it produces over-broad churn and a wider blast radius than the migration requires. Conversely, when the deprecated state is read directly inside each public caller (no helper), the deprecation belongs on the public callers — there is nothing lower to deprecate. Rule of thumb: identify which method contains the line you would otherwise need to change; deprecate that method. The deprecation chain should grow only as wide as the actual surface that touches the deprecated API. 16. Off-target replacement guard. Before editing any line, verify two things: (a) the line contains the target deprecated API for the active task, and (b) you're editing the deprecation the user asked about — not a nearby line that "looks similar."
---
Workflow
Phase 0: Fast Path for Simple Cases
Before reaching for the decision tree, check if the occurrence matches the simple case. A large fraction of UIScreen.main/UIScreen.mainScreen occurrences are simple substitutions inside a UIView/UIViewController instance method where the value is consumed fresh. These cases need no analysis — just substitute and move on:
| Original | Replacement |
|---|---|
UIScreen.main.scale (Swift) inside a UIView/UIViewController instance method, used inline (not stored) | self.traitCollection.displayScale |
[UIScreen mainScreen].scale (ObjC) inside a UIView/UIViewController instance method, used inline (not stored) | self.traitCollection.displayScale |
UIScreen.main.scale inside layoutSubviews, drawRect:, updateConstraints, or viewIsAppearing: | self.traitCollection.displayScale (no registration needed — UIKit auto-calls these on trait change) |
Do not over-think simple substitutions. If the enclosing class is UIView/UIViewController and the value isn't being assigned to an ivar, layer property, constraint, or stored image, just substitute. Empty diffs on simple files are the most common mistake — apply the substitution and move on. Reach for the decision tree only when the simple case doesn't fit (non-view class, cached value, class/static method, special user instructions).
Phase 1: Detection
Identify patterns to modernize using each relevant task file's detection patterns. Run detection for every task in the Task Registry that applies to this codebase, not just one — see Task Registry below.
Phase 2: Analysis
For each occurrence, read surrounding context to understand:
- Class hierarchy (UIView/UIViewController subclass vs plain NSObject vs non-view class)
- Method type (instance, static, free function, cached
dispatch_oncehelper) - Lifecycle phase (init, viewDidLoad, viewWillAppear, layoutSubviews)
- Code intent (layout, rendering, display scale, full screen dimensions)
The active task's reference file may add task-specific bullets to this list.
Use subagents to identify code that needs to be updated to keep your context window small.
Phase 3: Decision & Validation
| Condition | Action |
|---|---|
| Safe 1:1 replacement exists | Apply it. No added commentary (no // TODO: FIXME, no // TODO, no // FIXME — just the replacement). Use the replacement specified by the active task's reference file. |
| Multiple valid approaches or code relocation >10 lines | Ask the user. |
| No safe replacement possible (extremely rare) | Add todo with an explicit task outlined for the user. Never produce a silent empty diff. Re-check every pattern with a subagent before concluding nothing applies. |
Use subagents to validate against the active task's Post-file Checklist before any code change.
Phase 3b: File Processing Completeness
Process EVERY file that contains the target deprecated API. Do not stop early, skip files, or silently drop files from the work queue. A file that was identified in Phase 1 but produces no diff and no skip explanation is a processing failure.
Explicit file tracking: At the start of processing, write out the complete list of files to be modified using available task / todo tools or a markdown file. As you process each file, mark it done. Before finishing, compare this list against your output — any file without a diff or an explicit skip reason is a failure that must be addressed before completing.
Context size: If you are concerned about context size, use subagents to process individual files or tasks.
Silent-drop prevention: Before finishing, use subagents to compare the list of files you were given against the list of files you produced output for. If any file is missing from your output, go back and process it. Common causes of silent drops:
- File size: Large files (1000+ lines) are not exempt. Process them with the same approach.
- Complexity: Files with preprocessor macros, complex class hierarchies, or unusual code patterns still need changes.
- Project grouping: Do not skip all files from a specific project or directory. If you notice you've dropped multiple files from the same project, that indicates a systematic issue — investigate and fix.
- Ambiguity: If you're unsure how to fix a file, ask the user — do not silently produce an empty diff.
Large or complex files: Files with heavy preprocessor usage (#if/#ifdef nesting), 1000+ lines, or less common patterns (C++ interop, dispatch_once caching, deeply nested macros) are not exempt from processing. If the target API appears in such a file, apply the same decision tree. If the file is too large to edit in one pass, process the deprecated API usages one at a time. Use subagents if helpful. If you genuinely cannot determine a safe replacement due to macro expansion or preprocessor complexity, ask the user — never silently skip it.
Batch processing discipline: When processing a list of files, do NOT attempt to analyze all files first and then produce all diffs at once. Instead, process files one at a time or in small batches (3–5 files): read context, decide, produce the diff, then move to the next batch. This prevents the tail end of the file list from being silently dropped due to output limits or context exhaustion. If you notice you have produced output for fewer files than you were given, STOP and process the remaining files before finishing.
If you find empty diffs for files that should have straightforward replacements, go back and process them — straightforward files are fast to handle and should never be dropped.
Phase 4: Implementation
Apply the active task's implementation gates, rules, and post-file checklist from its reference file. The pattern-specific decision tree, gate questions, and validation rules live alongside the patterns they govern in each task file. Use subagents for verification.
Phase 5: Final Verification
File coverage audit: Use subagents to compare the list of files you were given (or detected in Phase 1) against the files you actually produced diffs for. Every input file must have a non-empty diff. If any file is missing changes, go back and process it now.
The active task's reference file may add task-specific verification steps.
---
Task Registry
Apply every task in this registry to the codebase unless the developer's request explicitly scopes to a subset. Each task is independent and has its own detection patterns, decision tree, and verification rules in its reference file. Run them in order from top to bottom.
| Task | File | Description |
|---|---|---|
| UIScreen.main modernization | uiscreen-task.md | Replace UIScreen.main with context-appropriate APIs |
| userInterfaceOrientation modernization | orientation-task.md | Replace layout-related orientation checks with size classes or window bounds |
| Scene lifecycle migration | scene-lifecycle-task.md | Migrate AppDelegate to SceneDelegate |
| Safe Area Insets | safe-area-task.md | Replace hard coded values for insets with safe area references and ensure that existing references work with asymetric safe areas |
Task: userInterfaceOrientation Modernization
Overview
userInterfaceOrientation (on UIApplication and UIViewController) and orientation on UIDevice encode orientation as an enum. Layout code that branches on orientation does not adapt to modern iOS — under multitasking, Stage Manager, and resizable scenes, "portrait vs landscape" no longer maps cleanly to the available space.
Detection patterns:
UIApplication.shared.statusBarOrientationUIApplication.shared.windows+ orientationUIDevice.current.orientationself.interfaceOrientation(deprecated UIViewController)- Any comparison against
UIInterfaceOrientationcases (.portrait,.landscapeLeft, etc.)
---
Scope: Layout-Related Uses Only
Only migrate uses that drive layout. A use is layout-related if it:
- Appears in a
UIVieworUIViewControllersubclass (or extension) - Appears in layout related methods like
layoutSubviews,updateProperties, etc. - Drives frame calculations, constraint setup, or visibility of UI elements
- Controls layout direction (horizontal vs vertical stacking)
Leave non-layout uses alone (camera capture, motion sensors, analytics, video recording). Add no TODO, make no change.
Orientation Locking (Non-Layout)
For apps locking orientation (e.g., games), the modern API is prefersInterfaceOrientationLocked (iOS 26+). Override in VC and call setNeedsUpdateOfPrefersInterfaceOrientationLocked() when preference changes.
Outside this task's auto-fix scope. When encountering supportedInterfaceOrientations or forced orientation APIs, add a TODO:
// TODO: Modernization - Consider adopting `prefersInterfaceOrientationLocked` (iOS 26+)
// as the modern replacement for orientation locking via `supportedInterfaceOrientations`.---
Step 1: Classify the Purpose
| Category | How to recognize | Replacement approach |
|---|---|---|
| Constrained space removal | Hides/removes UI in landscape to reclaim space | Size class check |
| Aspect ratio detection | Checks wider-than-tall to choose layout variant | Superview bounds comparison |
| Subview flow direction | Chooses horizontal vs vertical stacking | Size class or superview bounds |
---
Step 2: Apply the Correct Replacement
Pattern 1: Constrained Space → Size Class
| Original intent | Replacement |
|---|---|
| Narrow horizontal space (landscape iPhone) | traitCollection.horizontalSizeClass == .compact |
| Narrow vertical space (landscape iPhone hiding toolbar) | traitCollection.verticalSizeClass == .compact |
Use self.traitCollection in view/VC subclasses — never UITraitCollection.current when an instance is available.
---
Pattern 2: Aspect Ratio → Compare Window Bounds (only when clearly equivalent)
Do NOT replace with `width > height` heuristics when:
- Code distinguishes landscape-left vs landscape-right — window bounds cannot distinguish these
- Orientation drives animation direction or rotation transforms — these depend on actual orientation
- Replacement requires inventing heuristics (checking
window.transform) — never do this
In these cases, add a TODO explaining why bounds cannot substitute.
When replacement IS clearly equivalent (simple portrait-vs-landscape for layout):
// After
if view.bounds.height > view.bounds.width {
useVerticalLayout()
} else {
useHorizontalLayout()
}In view controller subclasses using view to check for the available size is correct. In view subclasses, using superview is appropriate.
---
Pattern 3: Subview Flow Direction → Size Class or View Bounds
Choose based on context:
- Decision "compact vs regular" → use size class (Pattern 1)
- Decision purely geometric ("wider than tall") → use view bounds (Pattern 2)
// Geometric — is the available space taller than wide?
stackView.axis = view.bounds.height > view.bounds.width ? .vertical : .horizontal
// Trait-based — compact width means stack vertically
stackView.axis = traitCollection.horizontalSizeClass == .compact ? .vertical : .horizontalTask: Safe Area Inset Modernization
Overview
In older versions of iOS, layouts hardcoded the heights of status bars (20pt), navigation bars (44pt), tab bars (49pt), and home indicators (34pt) and used topLayoutGuide / bottomLayoutGuide to position content under bars. Modern iOS exposes these via safeAreaInsets / safeAreaLayoutGuide, which already encode the geometry of the current device, orientation, and split-view configuration. Code that hardcodes those magic numbers, that re-uses one edge's inset for the opposite edge, or that infers display geometry from inset values needs to be updated.
Detection patterns:
- Deprecated guides:
topLayoutGuide,bottomLayoutGuide- Hardcoded bar heights used as constraint constants or in
UIEdgeInsets: - Common literal values to look for:
20(status bar),44(navigation bar),64(status + nav),88(status + large nav),34(home indicator),49(tab bar),83(tab + home indicator). - Patterns:
.constant = <literal>for those values,UIEdgeInsetsMake(<literal>, ...),UIEdgeInsets(top: <literal>, ...). - Symmetric / asymmetry misuse of
safeAreaInsets: - The same edge accessor used on opposite anchors (e.g.,
safeAreaInsets.leftapplied to leading and trailing in a ternary or paired calculation). max(safeAreaInsets.left, safeAreaInsets.right)applied to both sides.- Threshold checks like
safeAreaInsets.top > <literal>,safeAreaInsets.left > 0,safeAreaInsets.bottom > 0used as a proxy for display geometry. UIDevicemodel checks gating layout decisions.- Layout margin / RTL gaps:
- Writes to
layoutMargins(UIEdgeInsets) on a view, stack view, table view, or collection view (should bedirectionalLayoutMargins). viewRespectsSystemMinimumLayoutMargins = NO/falsewithout a justifying comment.- Manual frame math:
- Hardcoded numeric offsets in
layoutSubviews,viewWillLayoutSubviews, or manualframe =assignments that should derive fromsafeAreaInsets.
For each candidate, read the surrounding context to confirm the literal really is a bar offset (not a font size, animation duration, etc.) before treating it as a fix target. The rules below describe the fix for each confirmed candidate.
---
Rules
You are updating a UIKit codebase to properly account for modern layout margins and safe areas. Audit the code and apply the following changes:
1. Replace deprecated layout guides
- Replace all uses of
topLayoutGuideandbottomLayoutGuidewithview.safeAreaLayoutGuide. For example: topLayoutGuide.bottomAnchor→safeAreaLayoutGuide.topAnchorbottomLayoutGuide.topAnchor→safeAreaLayoutGuide.bottomAnchor
2. Fix hardcoded status bar / navigation bar offsets
- Remove hardcoded values like
20,44,64,88,34,49,83used as top/bottom insets to account for status bars, navigation bars, tab bars, or home indicators. Replace with constraints tosafeAreaLayoutGuideor usesafeAreaInsetswhen doing manual layout inlayoutSubviews.
3. Constrain to safe area instead of superview edges
- When a view should not underlap bars or device insets, pin to
safeAreaLayoutGuideanchors instead of the superview's edges. - When a view SHOULD extend under bars (e.g., background fills, scroll views), pin edges to superview but use
contentInsetAdjustmentBehavior = .automaticor setcontentInsetfromsafeAreaInsetsas appropriate.
4. Use directional layout margins
- Replace
layoutMargins(UIEdgeInsets) withdirectionalLayoutMargins(NSDirectionalEdgeInsets) to support RTL layouts. - Where views should respect the system minimum margins, ensure
viewRespectsSystemMinimumLayoutMarginsis not set tofalsewithout good reason. - Use
layoutMarginsGuidefor content that should be inset from the edges by the system-standard amount.
5. Handle safeAreaInsets in manual layout
- In any
layoutSubviewsor manual frame calculation, replace hardcoded inset values withsafeAreaInsetsfrom the relevant view. - In
viewSafeAreaInsetsDidChange, trigger layout updates if needed.
6. Remove assumptions about safe area inset symmetry and hardware placement
- Do NOT assume left and right safe area insets are equal. On devices in landscape with a sensor housing (e.g., iPhone with Dynamic Island), only one side has a nonzero horizontal inset. Apply each edge's inset independently using
safeAreaInsets.leftandsafeAreaInsets.right(or the leading/trailing anchors ofsafeAreaLayoutGuide). - Do NOT assume top and bottom safe area insets are equal or that one can be derived from the other. The top inset (status bar, Dynamic Island) and the bottom inset (home indicator) are independent values that vary by device and orientation.
- Do NOT assume hardware features like the notch, Dynamic Island, or camera housing are at a fixed edge or position. These features move depending on device orientation and vary across device generations. Code should never check for a specific device model or orientation to decide which edge has the sensor housing — rely solely on
safeAreaInsetsandsafeAreaLayoutGuide, which already encode the correct geometry for the current device and orientation. - Watch for patterns like:
- Using
safeAreaInsets.topfor both top and bottom - Using
safeAreaInsets.leftfor both left and right - Calculating a single "horizontal inset" as
safeAreaInsets.leftand applying it to both sides - Using
max(safeAreaInsets.left, safeAreaInsets.right)for both sides (unless the design explicitly requires symmetric padding) - Checking device model strings or
UIDeviceto infer which edges have hardware obstructions - Assuming the notch/Dynamic Island is always on the top edge
- Each edge must read its own corresponding inset value.
7. UIScrollView considerations
- Prefer
contentInsetAdjustmentBehavior = .automaticover manually settingcontentInsetfrom safe area values. - When using
adjustedContentInset, do not also manually add safe area insets (this double-insets).
8. Preserve existing visual behavior
- Do NOT change layouts that are intentionally edge-to-edge (backgrounds, media players, maps). Only adjust content that should respect safe areas.
- When in doubt, match the existing visual behavior — the goal is correctness on modern devices, not a redesign.
Constraints
- Do not introduce SwiftUI or any new dependencies.
- Minimize diff size: make the smallest change that fixes each issue.
- If a file has no issues, do not modify it.
Task: Scene Lifecycle Migration
Overview
UIKit apps must adopt scene-based lifecycle (UISceneDelegate) to function correctly on modern iOS. The system dispatches foreground/background transitions per-scene, not per-app — apps that only implement UIApplicationDelegate lifecycle methods miss these events in multi-window scenarios.
As of iOS 27, scene lifecycle is required. Apps built against the iOS 27 SDK that haven't adopted it crash at launch.
What this task does: Migrates from UIApplicationDelegate-only lifecycle to UISceneDelegate-based lifecycle in 3 sequential steps.
Cross-reference: Resolves UIWindow(frame: UIScreen.main.bounds) TODOs from uiscreen-task.md. After migration, use UIWindow(windowScene:) instead.
Reference: TN3187: Migrating to the UIKit scene-based life-cycle
---
Detection
Migration needed (proceed with all steps):
UIApplicationSceneManifestkey missing from Info.plist, AND- No
configurationForConnectingimplementation in AppDelegate, AND - No class conforming to
UIWindowSceneDelegatefound
Already migrated (STOP):
UIApplicationSceneManifestexists in Info.plist withUISceneConfigurations, OR- A class conforming to
UIWindowSceneDelegateexists
Partial migration (ask user):
- Scene manifest exists but
UISceneConfigurationsempty/missing configurationForConnectingexists but noSceneDelegateclassSceneDelegateexists but lifecycle methods not moved from AppDelegate
| What to search | Pattern |
|---|---|
| Scene manifest | UIApplicationSceneManifest in Info.plist |
| Dynamic config | configurationForConnecting in AppDelegate |
| Scene delegate | UIWindowSceneDelegate conformance |
| Lifecycle in AppDelegate | applicationDidBecomeActive, applicationWillResignActive, applicationDidEnterBackground, applicationWillEnterForeground |
---
Scope & Automation Level
| Action | Level |
|---|---|
Add UIApplicationSceneManifest to Info.plist | Auto-fix |
Create SceneDelegate boilerplate | Auto-fix |
Move UIWindow creation to scene delegate | Auto-fix |
| Move 4 lifecycle methods (all four together) | Auto-fix |
| Choose Info.plist vs dynamic configuration | Ask |
Split didFinishLaunchingWithOptions (one-time vs per-scene) | Ask |
Add SceneDelegate.swift to .pbxproj | Auto-fix |
| URL handling / user activity / notification migration | TODO |
Out of scope: Multiple window support (UIApplicationSupportsMultipleScenes set to false), external display support.
Do not repurpose a scene-lifecycle diff to swap an unrelated `UIScreen.mainScreen` reference. When the active task is the scene-lifecycle migration but the file also happens to contain a UIScreen.mainScreen use that is NOT part of UIWindow(frame: UIScreen.main.bounds) (which Step 2 legitimately resolves), leave that UIScreen.mainScreen reference for the UIScreen task. Do not, for example, substitute self.view (a view controller's view) for an unrelated screen reference, or swap [UIScreen mainScreen].scale to traitCollection.displayScale while doing scene-lifecycle work. If the scene-lifecycle migration genuinely cannot be applied to this file (no AppDelegate lifecycle methods, already migrated, etc.), report "skipped: [reason]" — do not produce a diff that swaps an unrelated UIScreen usage to look like progress was made.
---
Step 1: Add Scene Manifest to Info.plist
This step must complete before Step 2. The scene manifest activates the scene lifecycle system; without it, the system ignores SceneDelegate entirely.
Ask the user: "Should scene configuration be static (Info.plist — recommended) or dynamic (code in AppDelegate)?"
1A: Static Configuration (Info.plist) — Default
Add UIApplicationSceneManifest to the app's Info.plist:
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<!-- Include UISceneStoryboardFile only for storyboard-based apps -->
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>For programmatic root VC setup (no storyboard), omit the UISceneStoryboardFile key.
1B: Dynamic Configuration (Code in AppDelegate) — Alternative
Info.plist still needs a minimal manifest (without UISceneConfigurations):
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>// In AppDelegate.swift
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let config = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
config.delegateClass = SceneDelegate.self
return config
}For multiple scene roles, check connectingSceneSession.role to return the appropriate configuration.
---
Step 2: Create SceneDelegate
Requires Step 1 complete. The scene manifest must reference the delegate class.
2A: Storyboard-Based App
System handles window creation. SceneDelegate only needs the window property:
// TODO: Modernization - Add SceneDelegate.swift to the Xcode project's Compile Sources build phase.
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
}2B: Programmatic Root View Controller
Move window creation from AppDelegate to scene delegate:
// TODO: Modernization - Add SceneDelegate.swift to the Xcode project's Compile Sources build phase.
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = ViewController() // Replace with actual root VC
window?.makeKeyAndVisible()
}
}UIWindow(windowScene:) replaces UIWindow(frame: UIScreen.main.bounds) — no frame needed.
---
Step 3: Relocate Lifecycle Methods
Requires Step 2 complete.
3A: 1:1 Method Mappings
| AppDelegate | SceneDelegate |
|---|---|
applicationDidBecomeActive(_:) | sceneDidBecomeActive(_:) |
applicationWillResignActive(_:) | sceneWillResignActive(_:) |
applicationDidEnterBackground(_:) | sceneDidEnterBackground(_:) |
applicationWillEnterForeground(_:) | sceneWillEnterForeground(_:) |
Migrate the four methods as a set, not individually. The four events form a coherent observation cluster — observing some per-app and others per-scene produces mismatched counts on every multi-window state change. If all four bodies copy-paste cleanly to the scene equivalents (no UIApplication parameter access, no app-state branching), move all four. If any single method does not, do not migrate any of them in this pass.
Copy the method body unchanged; replace the UIApplication parameter with UIScene. Remove the moved methods from AppDelegate — if both exist, only the SceneDelegate version is called.
If the body calls helpers defined on AppDelegate, move them to SceneDelegate or to a shared utility. Accessing via UIApplication.shared.delegate is least preferred.
3B: didFinishLaunchingWithOptions — Always Ask
This method typically mixes one-time app setup and per-scene UI setup. Always ask the user which lines move.
Stays in AppDelegate: Analytics, database setup, push notifications, SDK initialization, global config.
Moves to SceneDelegate `scene(_:willConnectTo:options:)`: UIWindow creation, root VC setup, makeKeyAndVisible(), UI appearance config, state restoration. Window creation uses UIWindow(windowScene:) as shown in Step 2.
3C: Remove window Property from AppDelegate
After migration, window belongs on SceneDelegate. Remove var window: UIWindow? from AppDelegate. Search for and replace references: appDelegate.window, (UIApplication.shared.delegate as? AppDelegate)?.window → scene-appropriate access (e.g., view.window).
---
API Reference
| API | Minimum iOS |
|---|---|
UISceneDelegate / UIWindowSceneDelegate | iOS 13.0+ |
UIWindowScene / UIWindow(windowScene:) | iOS 13.0+ |
UISceneConfiguration | iOS 13.0+ |
UIApplicationSceneManifest (Info.plist) | iOS 13.0+ |
| Info.plist Key | Type | Description |
|---|---|---|
UIApplicationSceneManifest | Dictionary | Root key — activates scene lifecycle |
UIApplicationSupportsMultipleScenes | Boolean | false for single-window apps |
UISceneConfigurations | Dictionary | Static scene configurations |
UIWindowSceneSessionRoleApplication | Array | Standard window scene configs |
UISceneConfigurationName | String | Configuration identifier |
UISceneDelegateClassName | String | Scene delegate class name |
UISceneStoryboardFile | String | Main storyboard (omit for programmatic) |
Task: UIScreen.main Modernization
Overview
UIScreen.main reflects a single-window assumption and is now deprecated for window-relative use. Modern iOS supports multiple windows (iPad multitasking, Stage Manager, iPhone Mirroring), where UIScreen.main may not represent the display the calling code is rendering on.
Detection patterns:
UIScreen.main.scale/UIScreen.mainScreen.scaleUIScreen.main.bounds/UIScreen.mainScreen.boundsUIScreen.main.nativeBounds/UIScreen.mainScreen.nativeBoundsUIScreen.main.nativeScale/UIScreen.mainScreen.nativeScaleUIScreen.main.traitCollection/UIScreen.mainScreen.traitCollectionUIScreen.main.coordinateSpace/UIScreen.mainScreen.coordinateSpaceUIScreenBrightnessDidChangeNotificationwithUIScreen.main/UIScreen.mainScreenas object
Less-obvious sites that ALSO require modernization (do NOT produce empty diffs on them):
- Nil-screen fallbacks —
screen == nil ? [UIScreen mainScreen] : screen,self.window.screen ?: [UIScreen mainScreen],screen ?? UIScreen.main. The[UIScreen mainScreen]fallback IS a target site, even when wrapped in a nil check. See the Fallback Paths section below for the full handling. - Private helpers whose only `UIScreen` use is "incidental" — e.g., a
-(CGFloat)pixelWidthhelper that internally reads[UIScreen mainScreen].scale. The helper is the deprecation target, even if the caller looks unrelated to display rendering. - Cached `dispatch_once` / static-let / lazy-var helpers that read
UIScreen.mainonce at first call and freeze the value (e.g.,mainScreenScale(),isLargeDevice(),isRetina()). The helper itself is the target. - `UIScreen.main` passed as an argument to another function — e.g.,
MapsIdiomIsMac(UIScreen.mainScreen),UIRoundToScreenScale(value, UIScreen.mainScreen.scale). The argument is the target site; modernize it via the helper's owntraitCollection/parameter migration if available, or via deprecate-and-forward on the helper. However, only edit such an argument when the user explicitly asks for it — otherwise leave it for its own task per the off-target replacement guard ([Core Principle 16 in SKILL.md](../SKILL.md#core-principles)). - Hardware/screen assumptions where a TODO is the right output — when there's no safe replacement (e.g.,
UIScreen.main.nativeScalewith no trait-collection equivalent in a context where the call site can't yet receive a window), a TODO explaining the assumption IS the right output. Producing no diff is wrong — produce the TODO.
If a target appears outside this list (e.g., a safe-area-inset bug, a coordinate-space conversion site, a private method rename), follow the active task's reference file. The skill must NOT skip files because "this isn't a .scale substitution" — the trigger is the deprecated API appearing in a site, not the specific shape of the expression.
File-naming heuristic for non-view classes. Files named *Manager.m, *Provider.m, *DataProvider.m, *Bridge.m, *Helper.m, *Generator.m, *Ingester.m, *Source.m, *Downloader.m, *Processor.m, *ViewModel.swift are virtually never UIView/UIViewController subclasses. In these files, apply deprecate-and-forward (Pattern 1, step 5) with a new overload taking traitCollection: UITraitCollection.
---
Pattern 1: UIScreen.main.scale → traitCollection.displayScale
Intent: Get display scale for pixel-perfect rendering (2x, 3x).
These rules apply to any UIScreen.main.traitCollection access, not just .displayScale. The context (view vs non-view) determines the approach, regardless of which trait is being accessed.
Shared state is not a valid replacement. [UITraitCollection currentTraitCollection] / UITraitCollection.current carries the same single-display assumption as UIScreen.main and produces incorrect results in multi-window environments. Substituting it for UIScreen.main is not a modernization — it just renames the bug. The only legitimate use is as the forwarding bridge inside the deprecated wrapper of the deprecate-and-forward pattern (step 5), where the wrapper exists solely to point callers at a new overload that accepts traitCollection: explicitly. Anywhere else — view code, SwiftUI, free functions, helpers, fallbacks, examples — it is wrong. Treat the rest of this document accordingly: the only place you should write .current / currentTraitCollection is in the body of a deprecated forwarding wrapper.
Decision tree — follow in order, stop at the first match:
1. User provides an explicit replacement expression? → Use it exactly. The user chose that path for correct scene/window context. Never substitute a different path — the named path reflects the correct display context for that code site, and any substitute loses scene-specific information. 2. SwiftUI `View` struct? → Use @Environment(\.displayScale) private var displayScale as a property, then use displayScale at the call site. For UIScreen.main.bounds, use GeometryReader instead. Do NOT apply deprecate-and-forward to SwiftUI views. Even when the SwiftUI view has scale-dependent computation that "looks like" it would benefit from a traitCollection: parameter, the correct fix is @Environment(\.displayScale) — SwiftUI's environment propagation is the native mechanism. Introducing a traitCollection: UITraitCollection overload on a SwiftUI view is always wrong; it ignores the environment and forces callers to compute UIKit state in SwiftUI contexts. 3. UIView or UIViewController subclass (or extension), in an instance method? → self.traitCollection.displayScale. For class methods and static methods on view subclasses, skip to step 5 (deprecate-and-forward). 4. View/VC or trait collection reachable through a property or method parameter? → That object's .traitCollection.displayScale (e.g., self.contentView.traitCollection.displayScale or detailViewController.traitCollection.displayScale). Always prefer the most local source. Before constructing a path like self.editorViewController.contentView.traitCollection.displayScale, check whether a shorter source is available:
- Method parameters first (highest priority): If the method receives a view controller, view, or any object that already carries the value, use it directly. Do not navigate through the view hierarchy to get
displayScaleseparately. A method that receives a `traitCollection` parameter and ignores it is always wrong. - Local variables and direct properties next: If a local variable or direct property (
self.traitCollection) already has the needed value, prefer it over traversing a longer chain. Ifselfhas a view property (e.g.,self.view,self.contentView), useself.view.traitCollection.displayScale. - Multi-hop chains last: Only use a multi-hop path (3+ property accesses) when no shorter source exists. A long chain is fragile and harder to read. It also increases the risk of no longer providing the correct local value.
This step takes priority over step 5 ONLY when the class itself is a UIView/UIViewController subclass (i.e., the method is an instance method on a view/VC and you're reaching another view's traitCollection). If the class is a non-view class (*Manager, *Generator, *Provider, *Bridge, *Helper, *Source, etc.), step 5 (deprecate-and-forward) still applies — even if a view/VC is reachable via a property or parameter. In that case, use the reachable view's .traitCollection inside the new overload's body, but still create the three-part deprecation pattern. Simply inlining parameter.traitCollection.displayScale in a non-view class is a regression — it hides the traitCollection dependency from callers.
Exception: When a method already receives a traitCollection: parameter, use traitCollection.displayScale inside the body — no deprecation needed because the caller already provides the trait collection. 5. Non-view class, utility, static method, class method, or free function? → Apply the deprecate-and-forward pattern: keep the original method as a deprecated wrapper, add a new overload taking traitCollection: UITraitCollection, and have the deprecated wrapper forward to the new overload. This is the only context where shared state belongs in the forwarding body — see the pattern below for the exact shape.
Exception — smallest possible edit for file-local helpers: When the symbol meets ALL of the following, skip the deprecate-and-forward overhead and instead modify the existing signature in place, updating callers to pass traitCollection:
- Access:
private/fileprivate/static(Swift) or static C function / file-local helper (ObjC, no header declaration) - Reach: All call sites are in the same file (or in test code targeting only this file)
- Caller context: Every call site has a
traitCollectionreachable (typicallyself.traitCollectionfrom a UIView/UIViewController, or a parameter already in scope) - No public surface: The symbol is not part of a header, public API, protocol requirement, or
@objcexposed surface
For these symbols, the deprecate-and-forward pattern is over-introducing API surface — there are no external callers to protect. Inline the change: add the traitCollection parameter to the existing method, update the callers in the same file to pass self.traitCollection (or the appropriate local trait source), and ship a single coherent edit. This is the preferred choice for private helpers, single-file utilities, and test helpers.
Default to deprecate-and-forward when (a) the symbol is public / internal / open, (b) the symbol is declared in a header (ObjC), (c) callers exist in other files/modules that can't be updated atomically in this diff, or (d) the symbol is part of a protocol or override hierarchy. The full three-part pattern is mandatory in those cases.
Threading the trait collection through callers: When you keep the deprecated wrapper, callers that have a view/VC in scope must be updated separately to call the new overload directly with self.traitCollection — do not leave them on the deprecated path. Producing a new overload but leaving every caller on the deprecated wrapper defeats the purpose of the migration.
Applies to ALL access levels and both Swift and ObjC — ObjC class methods follow the same pattern. Place new parameter before any trailing closure. See the ObjC class method example below.
| Context | Replacement |
|---|---|
| SwiftUI `View` struct | @Environment(\.displayScale) private var displayScale |
| UIView/UIViewController subclass | self.traitCollection.displayScale |
| View/VC reachable via property or method parameter | someView.traitCollection.displayScale (prefer the most local source) |
| Non-view class / static / class method / free function | Deprecate-and-forward with traitCollection: UITraitCollection parameter |
| Test code | Use the object-under-test's traitCollection |
Two-part pattern: API swap + invalidation
A replacement in a view/VC has two parts: (A) the API swap, and (B) a registerForTraitChanges call when the value is cached. Both parts are mandatory for cached values — a diff with only part A is incomplete.
Both parts below are mandatory for cached values. Do not skip part B.
// COMPLETE — replacement + invalidation (both parts required)
class MyCell: UITableViewCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
imageView.layer.contentsScale = traitCollection.displayScale
registerForTraitChanges([UITraitDisplayScale.self]) { (self: MyCell, previousTraitCollection) in
self.imageView.layer.contentsScale = self.traitCollection.displayScale
}
}
}ObjC equivalent:[self registerForTraitChanges:@[UITraitDisplayScale.class] withHandler:^(typeof(self) self, UITraitCollection *previousTraitCollection) { ... }]or usewithAction:@selector(methodName)for a separate method.
Part B is NOT needed when the value is consumed fresh every time — in layoutSubviews, drawRect:, or a method called on-demand. See Invalidation Analysis.
Always prefer `registerForTraitChanges` over overriding `traitCollectionDidChange:` — even when older code or older docs use the older method.traitCollectionDidChange:is deprecated in iOS 17+, andregisterForTraitChanges([UITraitDisplayScale.self])(orregisterForTraitChanges:@[UITraitDisplayScale.class]in ObjC) is the correct modern form. SubstituteregisterForTraitChangeswhenever trait-change observation is needed, regardless of which method appears in the original code.
Deprecate-and-forward pattern (non-view classes)
Three required pieces: (1) deprecation, (2) new overload, (3) forwarding. Same structure regardless of access level (private, internal, public).
This pattern applies to ALL of the following — not just instance methods:
- Instance methods on non-view classes
- Static/class methods (
static func,class func, ObjC class methods) - Static computed properties (e.g.,
static var onePixel: CGFloat) — deprecate the property, introduce a newstatic funcwithtraitCollection:parameter - Computed properties (e.g.,
var displayScale: CGFloat) — deprecate the property, introduce a new method withtraitCollection:parameter - Protocol extensions (e.g.,
extension MyProtocol { func renderBadge() }) — deprecate the existing method in the extension, introduce a new method withtraitCollection:parameter - Free functions — deprecate the original, introduce a new function with
traitCollection:parameter
For static properties or protocol extensions where adding a parameter changes the API shape (property → function), that is expected and correct. The old property/method stays as the deprecated wrapper.
Apply deprecation at the lowest method that touches the deprecated API — not every public caller. When a chain of public methods (renderForLight, renderForDark, renderForAuto) all funnel into a single private helper (_renderWithStyle:) that is the only site touching UIScreen.mainScreen.scale, deprecate the helper. Adding a traitCollection: parameter to three public methods when the helper is the only one that needs it produces three times the API surface churn for the same migration. The wrapper public methods stay untouched — they pick up the new helper signature internally. Conversely, when each public caller reads UIScreen.main.scale directly inside its own body, deprecate each one individually — deprecate where the deprecated API actually lives.
Swift (do NOT delete the old method when adding a new overload):
// WRONG — old method removed, only new method left (breaks ABI for out-of-diff callers):
class ImageProcessor: NSObject {
func generateThumbnail(for image: UIImage, traitCollection: UITraitCollection) -> UIImage {
let scale = traitCollection.displayScale
return processImage(image, scale: scale)
}
// ← old generateThumbnail(for:) was deleted — out-of-diff callers can no longer compile,
// and there is no deprecation signal pointing them to the new API
}
// RIGHT — full deprecate-and-forward (all three parts mandatory, OLD METHOD KEPT):
class ImageProcessor: NSObject {
@available(*, deprecated, message: "use generateThumbnail(for:traitCollection:) instead")
func generateThumbnail(for image: UIImage) -> UIImage {
return generateThumbnail(for: image, traitCollection: .current)
}
func generateThumbnail(for image: UIImage, traitCollection: UITraitCollection) -> UIImage {
let scale = traitCollection.displayScale
return processImage(image, scale: scale)
}
}Swift initializers — the old initializer must remain as a deprecated wrapper:
// WRONG — old init removed:
class GlyphButton: UIButton {
init(glyph: Glyph, traitCollection: UITraitCollection) { ... }
// ← old init(glyph:) was deleted — callers that don't yet pass traitCollection break
}
// RIGHT — old init kept as deprecated wrapper:
class GlyphButton: UIButton {
@available(*, deprecated, message: "use init(glyph:traitCollection:) instead")
convenience init(glyph: Glyph) {
self.init(glyph: glyph, traitCollection: .current)
}
init(glyph: Glyph, traitCollection: UITraitCollection) { ... }
}Objective-C:
In headers (or above the implementation when no header exists), the old method's declaration MUST carry a real deprecation attribute — not just a comment. Use __attribute__((deprecated("use newMethod instead"))). A // Deprecated: comment alone does not generate compiler warnings for callers and is NOT sufficient.
// In ThumbnailGenerator.h — preferred default when UIKit/Availability headers are in scope:
@interface ThumbnailGenerator : NSObject
- (UIImage *)generateThumbnailForURL:(NSURL *)url __attribute__((deprecated("use generateThumbnailForURL:traitCollection: instead")));
- (UIImage *)generateThumbnailForURL:(NSURL *)url traitCollection:(UITraitCollection *)traitCollection;
@end
// In ThumbnailGenerator.m:
@implementation ThumbnailGenerator
- (UIImage *)generateThumbnailForURL:(NSURL *)url {
return [self generateThumbnailForURL:url traitCollection:[UITraitCollection currentTraitCollection]];
}
- (UIImage *)generateThumbnailForURL:(NSURL *)url traitCollection:(UITraitCollection *)traitCollection {
CGFloat scale = traitCollection.displayScale;
return [self renderThumbnail:url scale:scale];
}
@endFor private methods declared only in the implementation file (no header), put the attribute with the implementation:
- (UIImage *)renderBadge __attribute__((deprecated("use renderBadgeWithTraitCollection: instead"))); {
return [self renderBadgeWithTraitCollection:[UITraitCollection currentTraitCollection]];
}Objective-C class methods (`+` methods) — same pattern, not inline:
@interface BadgeAnimationGenerator : NSObject
+ (CAAnimation *)animation __attribute__((deprecated("use animationWithTraitCollection: instead")));;
+ (CAAnimation *)animationWithTraitCollection:(UITraitCollection *)traitCollection;
@end
@implementation BadgeAnimationGenerator
+ (CAAnimation *)animation {
return [self animationWithTraitCollection:[UITraitCollection currentTraitCollection]];
}
+ (CAAnimation *)animationWithTraitCollection:(UITraitCollection *)traitCollection {
CGFloat scale = traitCollection.displayScale;
// ... use scale ...
}
@endForwarding-chain consistency: When the new overload calls other methods on self or on wrapped/sub-objects, those calls must also use the traitCollection:-accepting version — not the deprecated version. A new method that internally calls object.deprecatedMethod instead of object.deprecatedMethod(traitCollection: traitCollection) silently ignores the passed traitCollection. Verify every call site within the new method's body.
When the user names a specific replacement path
When the user explicitly names a replacement path, use it exactly — even when a closer or "more convenient" trait source is available on self. The user named that specific source for a reason; substituting self.traitCollection to save a property hop loses scene-specific information.
---
Invalidation Analysis (mandatory for every displayScale replacement)
THIS CHECK IS NON-NEGOTIABLE. Every displayScale replacement in a UIView/UIViewController subclass must determine: is the value cached or consumed fresh? If cached, you must add a registerForTraitChanges call for UITraitDisplayScale — a replacement without invalidation is incomplete — the cached value goes stale on display change.
Default assumption: registration IS required. Only skip it when you can confirm one of the explicit exceptions below. When replacing UIScreen.mainScreen.scale (or .main.scale) with self.traitCollection.displayScale in code that computes a visual property (border width, image scale, constraint constant, image generation, layer property), you MUST add trait change observation. A `displayScale` replacement that feeds a cached or stored value MUST be paired with a `registerForTraitChanges` call — this is not optional, it is a hard requirement. Without it, cached values go stale when the user moves the window between displays. The exceptions are:
- (a) The code is inside a method that UIKit auto-calls on trait change:
layoutSubviews,drawRect:,updateConstraints,viewIsAppearing: - (b) The code is inside a private helper called exclusively from one of the above methods
If NONE of the exceptions apply, registration is required — period.
Registration pattern — register in init/setup, specify `UITraitDisplayScale`:
registerForTraitChanges([UITraitDisplayScale.self]) { (self: MyView, previousTraitCollection) in
// Recalculate the cached value(s)
}ObjC:[self registerForTraitChanges:@[UITraitDisplayScale.class] withHandler:^(typeof(self) self, UITraitCollection *previousTraitCollection) { ... }]. Alternative: usewithAction:@selector(methodName)when recalculation is in a separate method.
When registering for trait changes to update a cached value (layer lineWidth, borderWidth, contentsScale, constraint constant, ivar), the handler MUST directly recalculate that specific property. Do NOT use setNeedsLayout or setNeedsDisplay as the action — these only work if layoutSubviews or drawRect: happens to recalculate that exact property, which it usually does not. A setNeedsLayout that doesn't lead to recalculation of the cached value is a no-op bug.
// directly update the cached property:
registerForTraitChanges([UITraitDisplayScale.self]) { (cell: MyCell, previousTraitCollection) in
cell.layer.borderWidth = 1.0 / cell.traitCollection.displayScale
}Quick-reference: cached vs transient
Use this checklist to decide. If ANY cached indicator is true, registration is required.
Cached (registration required):
- Assigned to a layer property (
contentsScale,borderWidth,rasterizationScale,lineWidth) - Assigned to a constraint constant
- Stored in an ivar or property (
_cachedScale,_hairlineWidth) - Used to generate an image that is then stored (
button.setImage(...),imageView.image = ...) - Used inside a method that generates images for buttons, icons, badges, snapshots, or thumbnails — e.g.,
updateThemeButtonImages,updateBadgeImage,renderAppIcon,generateSnapshot. Even if the method computes fresh, its output is stored on a view or ivar. This is the most frequently missed case — generating a scale-dependent image and setting it on a button or image view without registering for trait changes means the image goes stale when the display scale changes. The trait change handler should call the same image-generation method. - Inside a setup method (
init,viewDidLoad,awakeFromNib,configure...,setup...,update...Images) that sets scale-dependent values on views — even if the method computes fresh, its output is stored - Used to compute a value passed to
CGAffineTransform,UIBezierPath, or drawing code called once during setup
Transient (no registration needed):
- Inside
layoutSubviews,drawRect:,updateConstraints,viewIsAppearing:— UIKit re-calls these on trait change - Inside a private helper that is ONLY called from one of the above methods
- Used in a local variable that doesn't escape the current scope and the method runs on-demand (not just once at setup)
- Inside a method triggered by user interaction (
@IBAction, gesture handler) — runs fresh each time
When in doubt, register. A redundant registration is harmless; a missing one causes stale rendering on display changes.
Examples: when registration IS needed
Cached in init:
override init(frame: CGRect) {
super.init(frame: frame)
separatorLine.lineWidth = 1.0 / traitCollection.displayScale
registerForTraitChanges([UITraitDisplayScale.self]) { (self: MyView, previousTraitCollection) in
self.separatorLine.lineWidth = 1.0 / self.traitCollection.displayScale
}
}Cached image:
func updateThemeButtonImages() {
let scale = traitCollection.displayScale
let renderer = UIGraphicsImageRenderer(size: size)
cachedButtonImage = renderer.image { context in /* ... */ }
button.setImage(cachedButtonImage, for: .normal)
}
// In init or setup — handler INVOKES the existing method, never duplicates its body:
registerForTraitChanges([UITraitDisplayScale.self]) { (self: MyView, previousTraitCollection) in
self.updateThemeButtonImages()
}Never duplicate the update method's body inline in the handler. The handler's job is to callupdateThemeButtonImages()— not to copy the renderer/setImage code into the handler block. Inline duplication creates two parallel implementations that drift the moment anyone fixes a bug in one. If a method likeupdateThemeButtonImages/updateBadgeImage/renderAppIcon/configureSeparatoralready exists, the handler must call it by name. ObjC equivalent: preferwithAction:@selector(updateThemeButtonImages)over awithHandler:block that re-implements the body.
---
Pattern 2: UIScreen.main.bounds → view.bounds
Intent: Get available space for layout or dimensions.
Do NOT replace with self.bounds when the code is asking "how big is the display area." The local view's bounds represent its own size, not the available screen/window space.
Do NOT use ?? 0 or ?? .zero as fallback for window bounds. Refactor the API to accept size as a parameter, or move to a lifecycle point where window is guaranteed.
| Context | Replacement |
|---|---|
UIView/UIViewController in loadView or init (initial frame) | CGRectZero / .zero. Never access self.view in loadView — causes infinite recursion. Auto Layout resizes before display. |
| UIViewController in safe lifecycle methods | self.view.bounds |
| UIView in safe lifecycle methods | self.superview.bounds |
| UIView/UIViewController in unsafe methods | Move code to viewIsAppearing for view controllers and layoutSubviews for views or later |
| Non-view class / static / free function | Add bounds: CGRect parameter, deprecate original |
`CGRectZero` is ONLY for `loadView`/`init`. SubstitutingCGRectZerofor[UIScreen mainScreen].boundsin any other context (instance methods pastviewDidLoad, layout helpers, sizing computations) produces a zero-sized layout that breaks the feature. If the call site is in a safe lifecycle method, useself.view.bounds(view controller) orself.superview.bounds(view). Ifviewmay be nil, move the code or ask the user — but never substituteCGRectZerooutsideloadView/init.
Safe view controller methods (view hierarchy guaranteed): viewIsAppearing, viewDidAppear, viewWillDisappear. Unsafe view controller methods (view may not be in a view hierarchy): init, loadView, viewDidLoad, viewWillAppear.
Non-view class (deprecated wrapper):
class LayoutHelper {
@available(*, deprecated, message: "Pass bounds from the caller's window or view context")
static func calculateOptimalWidth() -> CGFloat {
// TODO: Modernization - Callers should pass bounds from their window/view context
return calculateOptimalWidth(in: UIScreen.main.bounds)
}
static func calculateOptimalWidth(in bounds: CGRect) -> CGFloat {
return bounds.width * 0.9
}
}The deprecated wrapper keepsUIScreen.main.boundsas a temporary bridge. Never replace the bridge withUIApplication.shared.connectedScenesor other shared state references.
---
Pattern 3: UIScreen.main.nativeScale — NO trait-collection equivalent
nativeScale is the physical pixel density of the hardware display; displayScale/scale is the logical scale factor (2x, 3x). There is no trait-collection equivalent — it must come from a screen object. Same applies to nativeBounds and coordinateSpace.
// Before
let nativeScale = UIScreen.main.nativeScale
// After
let nativeScale = window.windowScene.screen.nativeScaleAlways use `window.windowScene.screen`, not window.screen. In multi-scene environments, window.screen may not reflect the correct display — windowScene.screen ensures the screen is resolved through the scene's connection to its display. This applies to all screen properties accessed via window: nativeScale, nativeBounds, scale, bounds, coordinateSpace. Using self.view.window.screen.nativeScale instead of self.view.window.windowScene.screen.nativeScale is always wrong.
---
Pattern 4: Keyboard Notification Coordinate Space
Intent: Convert keyboard frame from notification using a coordinate space.
When handling keyboard notifications (UIKeyboardWillShowNotification, UIKeyboardWillChangeFrameNotification, etc.), the notification's object is the screen posting the notification. Use notification.object to get the coordinate space — never substitute self.view.window.screen or self.view.window.windowScene.screen.
// WRONG — indirect path, may be nil:
CGRect keyboardFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
CGRect converted = [self.view.window.screen.coordinateSpace convertRect:keyboardFrame toCoordinateSpace:self.view];
// RIGHT — notification.object IS the screen:
CGRect keyboardFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
CGRect converted = [((UIScreen *)notification.object).coordinateSpace convertRect:keyboardFrame toCoordinateSpace:self.view];This is the correct approach because: 1. notification.object is guaranteed to be the screen — it's always available 2. self.view.window may be nil if the view isn't in the hierarchy yet 3. In multi-screen environments, notification.object is the specific screen, not necessarily the main screen
---
Special Cases
Free Functions and Cached Helpers
When UIScreen.main appears inside a free function, dispatch_once helper, or cached wrapper (e.g., mainScreenScaleFactor(), isLargeDevice(), isRetina()), the TODO belongs at the top of the function — not next to the UIScreen usage. The function itself is the problem. Also add a TODO at every call site.
// TODO: Modernization - This cached helper assumes a single screen scale. Convert callers to pass
// traitCollection.displayScale from their view/VC context. Once all callers are migrated, remove this function.
func mainScreenScaleFactor() -> CGFloat {
// ... cached dispatch_once returning UIScreen.main.scale
}
// At each call site:
// TODO: Modernization - Replace mainScreenScaleFactor() with self.traitCollection.displayScale
self.layer.contentsScale = mainScreenScaleFactor()For device-type cached helpers (isLargeDevice(), isCompactDevice()): the TODO must explain that with flexible windowing and iPhone Mirroring, cached screen-size checks no longer reflect the active window's dimensions. Call sites should use size classes or window bounds.
Notification Observers
When migrating UIScreen.mainScreen in notification observers, the TODO must note that the screen can change when a window moves between displays. The observation needs to track screen changes and re-subscribe.
// TODO: Modernization - UIScreen.mainScreen assumes a fixed screen. When a window moves between
// displays, the screen changes. Track the window's current screen, observe brightness on that
// screen, and re-subscribe when the screen changes (e.g., via windowScene.screen updates).
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(brightnessChanged:)
name:UIScreenBrightnessDidChangeNotification
object:UIScreen.mainScreen];Fallback Paths
When code already has self.window.screen ?: UIScreen.mainScreen, keep the window-based access (correct path). Only address the fallback:
// TODO: Modernization - The UIScreen.mainScreen fallback assumes a single display. Consider
// what should happen when self.window is nil (e.g., return early or defer until window is set).
UIScreen *screen = self.window.screen ?: UIScreen.mainScreen;When code already has self.traitCollection.displayScale with a UIScreen.mainScreen.scale fallback (e.g., self.traitCollection.displayScale ?: UIScreen.mainScreen.scale), remove the entire fallback and use just `self.traitCollection.displayScale`. The fallback is not needed as local trait collections provide their own fallback value.
// Before — ternary fallback:
CGFloat scale = self.traitCollection.displayScale ?: UIScreen.mainScreen.scale;
// RIGHT — remove fallback entirely:
CGFloat scale = self.traitCollection.displayScale;When removing a UIScreen fallback where self.traitCollection is available, remove the entire fallback — do NOT substitute 1.0, ?: 1, or any other literal or invented value. If the original code was self.traitCollection.displayScale ?: UIScreen.mainScreen.scale, the correct replacement is self.traitCollection.displayScale — not self.traitCollection.displayScale ?: 1. The replacement must not introduce a fallback that was not present in the original non-UIScreen code path.
Magic-number substitution is forbidden across the board. When the original fallback is guarding something other than scale (e.g., a layout constant, a default width, a layout-driven offset), do NOT collapse the expression by substituting an invented literal for the screen-derived value. Examples of forbidden replacements:
// WRONG — invented magic number replaces the screen-derived value:
// Original: CGFloat width = useFullWidth ? [UIScreen mainScreen].bounds.size.width : 262.f;
CGFloat width = useFullWidth ? 262.f : 262.f; // ← magic number invented to remove UIScreen
// WRONG — CGRectZero substituted for screen bounds outside loadView/init:
// Original: CGRect frame = [UIScreen mainScreen].bounds;
CGRect frame = CGRectZero; // ← only safe in loadView/init; produces zero-sized layout elsewhere
// RIGHT — preserve the surrounding control structure with the correct context:
CGFloat width = useFullWidth ? self.view.window.bounds.size.width : 262.f;If the surrounding code was using the screen as a way to get "available space," the correct replacement is self.view.bounds in view controllers and self.superview.bounds in views. If you genuinely cannot determine a safe replacement, ask the user — never substitute a magic number to make the deprecation go away.
When the original code has a ternary where both branches compute the same semantic value (display scale) via different accessors — e.g., self.window.screen ? self.window.screen.scale : UIScreen.mainScreen.scale — and self.traitCollection.displayScale provides that same value correctly, simplify the entire expression to self.traitCollection.displayScale. The ternary's purpose was to avoid the UIScreen fallback when a better source was available; traitCollection.displayScale serves that purpose directly without the nil-check.
Important distinction: This full-expression simplification applies only when both branches compute the same value (e.g., both get display scale). When the primary path computes a different value or uses a different public API (e.g., window.screen.nativeScale vs UIScreen.mainScreen.scale), preserve the primary path and only replace the UIScreen fallback.
UIWindow Initialization
Replace UIWindow(frame: UIScreen.main.bounds) only when a windowScene is locally available. Otherwise add a TODO — never fetch from connectedScenes.
// windowScene in scope → safe to replace
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
}
// windowScene not available → add TODO
// TODO: Modernization - Replace with UIWindow(windowScene:) by accepting a UIWindowScene parameter
// or moving initialization to scene(_:willConnectTo:options:).
private let window: UIWindow = UIWindow(frame: UIScreen.main.bounds)SwiftUI
Replace UIScreen.main.bounds with GeometryReader. For display scale, use @Environment(\.displayScale). If GeometryReader adoption is too complex, add a TODO.
// In a SwiftUI View struct:
@Environment(\.displayScale) private var displayScale
// ... in body:
imgRenderer.scale = displayScaleUIGraphicsImageRendererFormat(for: UIScreen.main.traitCollection)
This pattern passes a traitCollection to a format initializer. Never remove the `for:` argument — always pass a trait collection through it.
Apply the full deprecate-and-forward pattern to the enclosing method so callers can pass the correct trait collection:
// Deprecate-and-forward on the enclosing method:
@available(*, deprecated, message: "use renderBadge(traitCollection:) instead")
func renderBadge() -> UIImage {
return renderBadge(traitCollection: .current)
}
func renderBadge(traitCollection: UITraitCollection) -> UIImage {
let format = UIGraphicsImageRendererFormat(for: traitCollection)
// ...
}// ObjC equivalent (real deprecation attribute on the declaration — prefer API_DEPRECATED_WITH_REPLACEMENT):
- (UIImage *)renderBadge __attribute__((deprecated("use renderBadgeWithTraitCollection: instead")));
- (UIImage *)renderBadgeWithTraitCollection:(UITraitCollection *)traitCollection;
// In the implementation:
- (UIImage *)renderBadge {
return [self renderBadgeWithTraitCollection:[UITraitCollection currentTraitCollection]];
}
- (UIImage *)renderBadgeWithTraitCollection:(UITraitCollection *)traitCollection {
UIGraphicsImageRendererFormat *format = [[UIGraphicsImageRendererFormat alloc] initForTraitCollection:traitCollection];
// ...
}This applies even to private methods — the deprecation signals intent and enables future callers to pass the correct trait collection.
Call-Chain Propagation
When adding a traitCollection parameter to method A, check callers. If a caller also lacks a local trait collection (non-view class), apply the same deprecate-and-forward pattern. Repeat until the chain reaches a UIView/UIViewController (self.traitCollection).
---
Analysis
In addition to the generic context read described in SKILL.md Phase 2:
- Cached vs on-demand — if
displayScaleis stored in an ivar/property/constraint/layer during init/setup, aregisterForTraitChangescall forUITraitDisplayScaleis needed (see Invalidation Analysis above).
Implementation Gates
Before editing any line, answer these five gate questions:
1. SwiftUI context? Is this inside a struct conforming to View?
- YES → Use
@Environment(\.displayScale)for scale,GeometryReaderfor bounds. - NO → Continue to question 2. Never introduce SwiftUI patterns (`@Environment(\.displayScale)`, `GeometryReader`) into a `UIView` or `UIViewController` subclass. Use
self.traitCollection.displayScale— the UIKit API — even if the project also contains SwiftUI code.
2. Cached value? Is the replaced value stored in a layer property, constraint, ivar, image, or button image? Or does the replacement appear inside a setup method that sets images on views (e.g., updateThemeButtonImages, updateBadgeImage, renderAppIcon)? Or inside init/viewDidLoad/awakeFromNib/configure/setup where the computed value is stored and never recomputed? Or has the user explicitly asked you to register for trait changes? Use the [cached-vs-transient quick-reference](#quick-reference-cached-vs-transient) to decide.
- YES → You MUST add a
registerForTraitChanges([UITraitDisplayScale.self])call with either a `withHandler:` block or a `withAction:` selector. A bareregisterForTraitChangeswith only a trait list and no handler is a compile error. A diff without registration is incomplete — the cached value will go stale on display change. The inline API swap alone is insufficient for cached values — it only fixes the initial computation but breaks when the user moves between displays with different scales. See Invalidation Analysis for cached-value indicators. This is the most commonly missed check — verify it for every file. When in doubt, register — a redundant registration is harmless, a missing one causes stale rendering. - NO → Skip the override.
Common blind spot: Methods named update*Images, update*Image, render*, generate*, createSnapshot* that produce scale-dependent images and set them on views. Even though these methods compute fresh values, their outputs are stored (on buttons, image views, ivars). If called from init/viewDidLoad, you MUST register for trait changes and re-call the method in the handler. This is the most commonly missed pattern. A replacement that swaps the API call but omits `registerForTraitChanges` for a cached value is incomplete — even if the inline replacement is correct, the cached output goes stale. The two parts (API swap + registration) are inseparable for cached values. 3. View or non-view class? Does this class inherit from UIView or UIViewController?
- YES, instance method → use
self.traitCollection.displayScale - YES, but class method or static method → Apply step 5 (deprecate-and-forward).
- NO, but method already receives a `traitCollection:` parameter → use
traitCollection.displayScaleinside the method body. No deprecation needed — the caller already provides the trait collection. - NO, but view/VC reachable via property/parameter → use that object's
.traitCollection.displayScale. Always prefer the most local source. If the method receives a view or view controller parameter, use its.traitCollection.displayScale. Prefer a direct property over a multi-hop chain (3+ property accesses). - NO, and no view/VC reachable → apply the deprecate-and-forward pattern (new overload + deprecation + forwarding). Both ObjC and Swift — there is no exception. This is mandatory: an inline replacement in a non-view class is always wrong — apply the full three-part pattern instead. This is the most common mistake in Swift files: create a new method overload with
traitCollection: UITraitCollection, deprecate the old method, and have the old method forward to the new one. Classes named*Provider,*Downloader,*Manager,*ViewModel,*Processor,*Helper,*Generator,*Bridge,*Source,*DataProviderare almost never view subclasses. The new overload must accepttraitCollection: UITraitCollection(notdisplayScale: CGFloat).
4. Dead code? Is this inside #if 0/#endif or #if false? → Do not modify, modernize, or replace code within the dead block. The code was already dead; modernizing it is pointless. 5. Different deprecation? Before editing a line, verify it contains the target API (UIScreen.main/UIScreen.mainScreen). If the line instead contains interfaceOrientation, UIDevice.current.orientation, UIInterfaceOrientationIsLandscape, UIInterfaceOrientationIsPortrait, statusBarOrientation, verticalSizeClass, horizontalSizeClass, or any other deprecation — do not touch it. Each task is independent. This is the #1 source of out-of-scope changes. Even if the deprecated line is adjacent to or interleaved with UIScreen lines, leave it for its own task. This applies per-line: read the original line before writing the replacement. If the original line does not contain the target API string, your edit is out of scope — revert it immediately.
Implementation Rules
1. Preserve code style and formatting. Handle both Swift and Objective-C. 2. Scope rule: Only modify lines containing the target deprecated API. If a line in your diff does not contain the target API in the original, the change is out of scope — revert it. Do not touch other deprecations, reformat code, or fix unrelated issues. Cross-task contamination is an issue: when working on UIScreen replacements, do NOT also fix interfaceOrientation, UIDevice.current.orientation, self.interfaceOrientation, UIInterfaceOrientationIsLandscape, UIInterfaceOrientationIsPortrait, verticalSizeClass/horizontalSizeClass conversions, landscape detection logic, or other deprecations that appear nearby in the same file. Each task in the Task Registry is independent. Even if you see an obvious modernization opportunity on an adjacent line, leave it alone. Concrete example of a wrong change: Replacing UIInterfaceOrientationIsLandscape(self.interfaceOrientation) with a verticalSizeClass == .compact check while doing UIScreen work — this is an orientation modernization, not a UIScreen modernization, and must not be included. Only make changes that are directly covered by the active task. Do not make additional "bonus" fixes to nearby code, even if they address related deprecations. A diff that touches lines not containing the target API is out of scope. 3. Invalidation rule: When the user explicitly asks to register for trait changes — add it. When the user is general — determine if the value is cached (see gate question 2). If cached, add registerForTraitChanges([UITraitDisplayScale.self]) with a handler that recalculates. If consumed fresh, skip. Always use `registerForTraitChanges` — even when the original code uses `traitCollectionDidChange:`. traitCollectionDidChange: is deprecated in iOS 17+ and the modern API is the recommended form. Register for the specific trait class (e.g., UITraitDisplayScale) rather than checking all trait changes. Always use a withHandler: block that directly sets the property, or a withAction: selector pointing to a method that directly recalculates it. 4. Replacement path rule: When the user provides an explicit replacement expression, use it exactly. Do not substitute a generic fallback or shorter path. The named path reflects the correct scene/display context — substituting it loses that context. Method parameters always take priority. When a method parameter directly provides the needed value (e.g., a CALayer *layer parameter has layer.contentsScale, a view parameter has .traitCollection.displayScale), use the parameter — even if a longer path through self would also work. The parameter is the most local, most reliable source. A method that ignores an available layer parameter and instead navigates through self.someController.someView.traitCollection.displayScale is always wrong — use layer.contentsScale. When a notification's object provides the needed value (e.g., notification.object is the screen for UIScreenBrightnessDidChangeNotification, or notification.object.coordinateSpace for keyboard notifications), use notification.object — never substitute self.view.window.screen or another indirect path. If the user names a specific view's trait collection, that path is mandatory — not optional. 5. Parameter type rule: When introducing a new method overload for deprecate-and-forward, the parameter must be traitCollection: UITraitCollection (Swift) or traitCollection:(UITraitCollection *)traitCollection (ObjC). Never use displayScale: CGFloat or scale: CGFloat. Extract .displayScale inside the new method body. This ensures callers pass the full trait collection, enabling future use of other traits without another API change. User-instruction exception: when the user explicitly asks for a different parameter (e.g., scale: CGFloat), use exactly the parameter name, type, and position they specify. Parameter position: when the user is general, place the new parameter at the end (before any trailing closure). When the user specifies a position, use that position exactly — do NOT move it to the end. 7. ObjC deprecation attribute rule: In Objective-C, every deprecate-and-forward old method must carry a real deprecation attribute on its declaration — not just a comment. Default to `__attribute__((deprecated("use <newMethodName> instead")));`. User-instruction exception: when the user explicitly asks for a particular attribute, follow that — the default only applies when the user is general. The attribute belongs in the header where the method is declared; for private methods without a header, place it at the implementation. A // Deprecated: comment alone does NOT produce compiler warnings for callers and is insufficient. Apply this consistently to every ObjC deprecate-and-forward in a file. 8. All occurrences rule: Replace ALL UIScreen.main/UIScreen.mainScreen occurrences in a file, including those inside utility function/macro calls (e.g., UIRoundToScreenScale(UIScreen.mainScreen.scale, ...) — replace the UIScreen.mainScreen.scale argument with self.traitCollection.displayScale). Leaving some occurrences unchanged while fixing others is a partial fix and leaves the file half-migrated. 9. Ternary preservation rule: When existing code has a ternary with a non-UIScreen primary path, check whether both branches compute the same semantic value (e.g., both get display scale). If yes and self.traitCollection.displayScale provides that value, simplify the entire expression. If the primary path computes a different value or uses a valid public API for a different purpose, only replace the UIScreen fallback branch — do not remove or restructure the primary path. 10. Utility function rule: When existing code uses utility functions that wrap UIScreen.main.scale (e.g., UIRoundToScreenScale(value, UIScreen.mainScreen.scale), UIRoundToScale), prefer replacing the UIScreen argument with the modern equivalent while keeping the utility function call — do not reimplement the utility function's logic inline. For example, replace UIRoundToScreenScale(value, UIScreen.mainScreen.scale) with UIRoundToViewScale(value, self.view) or UIRoundToScale(value, self.traitCollection.displayScale) rather than manually inlining (scale > 0) ? round(value * scale) / scale : value. 11. Forwarding-chain consistency rule: When a new method overload (from deprecate-and-forward) calls other methods on self or on wrapped/sub-objects, those calls must also use the traitCollection:-accepting version — not the deprecated version. A new method that internally calls the deprecated API on a sub-object silently ignores the passed traitCollection. This is a correctness bug. Verify ALL code paths: if the new method has branches (if/else, switch, guard/else, optional binding), check EVERY branch — not just the happy path. A common bug is correctly using traitCollection in one branch but falling back to the deprecated path in another. 12. Existing parameter preservation rule: When a method already has a parameter that provides scale information (e.g., displayScale: CGFloat, scale: CGFloat), do NOT change that parameter's type to UITraitCollection. Replace the UIScreen usage inside the method body using the existing parameter. Only add a new traitCollection: UITraitCollection parameter when introducing a NEW method overload where the original method had no way to receive the value. Changing an existing CGFloat parameter to UITraitCollection is a broader API change than needed and breaks callers.
13. Defensive-guard preservation rule: Leave unrelated defensive logic that wraps the screen access intact. respondsToSelector: checks, nil-window guards, #available/@available version checks, and similar conditionals exist for reasons unrelated to the deprecation — modernize only the UIScreen.mainScreen reference, not the conditional that wraps it. Failure pattern: an if/else with a respondsToSelector: check on the primary path and a UIScreen fallback on the else branch — replace the UIScreen fallback only, not the entire if/else. Multiple constructor paths (e.g., `initWithFrame:` AND `awakeFromNib`) that each register handlers must NOT be consolidated — both code paths exist for object-creation differences (programmatic vs. nib loading) that the modernization has no opinion about.
Post-file Checklist
Verify before moving to the next file:
- [ ] Cached value (layer property, constraint, ivar, stored image, button image, setup/image-generation method output) →
registerForTraitChangespresent? Both API swap and registration are required for cached values — independent of any deprecate-and-forward also applied in this file. - [ ]
registerForTraitChangespresent → haswithHandler:orwithAction:? In a one-time setup method (notlayoutSubviews)? Handler directly recalculates the property (notsetNeedsLayoutas proxy)? - [ ]
loadViewcontext →CGRectZero/.zerofor initial frame? Never accessself.view(infinite recursion crash). - [ ] View/VC instance method →
self.traitCollection? - [ ] Class method or static method → deprecate-and-forward (not
self.traitCollection)? - [ ]
CALayer *layerparameter available →layer.contentsScale? Applies even in non-view classes. - [ ] Non-view class → full deprecate-and-forward (not inline)? Applies to
*Provider,*Manager,*Helper,*Generator,*Bridge,*Source,*DataProvider, static computed properties, protocol extensions. Verify: NEW method withtraitCollection: UITraitCollection,@available(*, deprecated)on old, deprecated wrapper forwards to the new overload. Applies regardless of project context or class name. Exception:private/fileprivate/staticsymbol with all callers in the same file → use the smallest-edit rule (modify signature in place, update in-file callers) per the file-local helper exception in Pattern 1, step 5. - [ ] Old method/initializer KEPT as deprecated wrapper (not deleted)? When adding a new overload via deprecate-and-forward, the original declaration must remain in the file with the deprecation attribute. Removing it breaks ABI for out-of-diff callers and strips the migration signal.
- [ ] Unrelated guards preserved?
respondsToSelector:checks, nil-window guards,#available/@availablechecks, multiple constructor paths (initWithFrame:ANDawakeFromNib) — all left intact unless the user explicitly asks to remove them. - [ ] ObjC deprecate-and-forward → real
__attribute__((deprecated(...)))attribute on the declaration (not just a// Deprecated:comment)? - [ ] Deprecate-and-forward applied → are in-diff callers with a view in scope updated to call the new overload directly with
self.traitCollection(not still on the deprecated wrapper)? - [ ] No whitespace-only edits? Every changed line is part of the targeted replacement or a structural part of the new pattern.
- [ ] Nil-screen object fallback removed (
screen ?: [UIScreen mainScreen]) → either kept an equivalent guard or added a TODO surfacing the new "non-nil screen assumed" behavior? - [ ] Existing
CGFloatscale parameter preserved (not changed toUITraitCollection)? - [ ] Multiple methods need deprecate-and-forward → applied to ALL consistently?
- [ ]
UIGraphicsImageRendererFormat(for:)→ deprecate-and-forward on enclosing method (not inline swap, not removingfor:argument)? - [ ] Screen via window uses
window.windowScene.screen? - [ ] *If the file already has an `update
/render` / `configure` method that produces the cached value, the trait-change handler invokes it by name (not duplicating its body inline)?** - [ ] Deprecation applied at the lowest method that touches the deprecated API (helper, when several public callers funnel into one) — not duplicated across every public caller?
- [ ] New overload's parameter is `traitCollection: UITraitCollection`, NOT a scalar (`displayScale: CGFloat`, `contentsScale: CGFloat`, `scale: CGFloat`)? Use a scalar only when the user explicitly asks for one.
- [ ] *Edited line actually contains the active task's target API at the intended site (not a nearby line that "looks similar," e.g., a different `UIScreen.main.` accessor or a different observer registration)?**
- [ ] No unrelated changes? Every changed line must contain
UIScreenin the original. - [ ] Bounds consistency? If multiple
UIScreen.mainScreen.boundsreplacements, all use same target. - [ ] Control flow preserved? Branch count before = branch count after.
- [ ] No dead code modified?
- [ ] Forwarding chain correct? New overload doesn't call deprecated APIs internally — check ALL branches, not just the happy path.
Atomic completeness check (most critical — verify this last):
- [ ] If this file needed BOTH an API swap AND
registerForTraitChanges→ are BOTH present in the diff? (Not "I'll add it later" — both must be in this diff.) - [ ] If this file needed deprecate-and-forward → does the diff contain all THREE parts (deprecation + new overload + forwarding)? An inline replacement when the pattern calls for method extraction is always wrong.
Final Verification
In addition to the generic file-coverage audit in SKILL.md Phase 5:
1. Multi-part completeness audit: For every file where you applied an API replacement, verify:
- If the value is cached → does the diff also include
registerForTraitChanges? If not, add it now. The API swap alone is never sufficient for cached values. - If the active task calls for deprecate-and-forward → does the diff contain all three parts (deprecation annotation + new overload + forwarding)? If you only did an inline replacement, redo it with the full pattern.
- Both requirements (trait registration AND deprecate-and-forward) may apply to the same file independently. Completing one does not satisfy the other.
2. Forwarding correctness audit: For every new method overload you created, verify that ALL code paths within the new method use the passed traitCollection parameter — not the deprecated overload, not UIScreen.main. If any branch ignores the parameter, fix it now.
---