
Ios Storyboard
- 213 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
ios-storyboard: A skill for development. This provides functionality for development workflows.
Key points
- ios-storyboard
Ios Storyboard by the numbers
- 213 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,892 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill ios-storyboardAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 213 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use ios-storyboard for development tasks?
Use ios-storyboard for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with ios-storyboard.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use ios-storyboard for development tasks, or when ios-storyboard: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to ios-storyboard: ios-storyboard.
Files
iOS Storyboard Best Practices
Legacy interoperability guidance for storyboard-heavy code that still exists in clinic projects. Not for new SwiftUI clinic feature development.
Comprehensive UI design and architecture guide for Xcode Storyboard and Interface Builder, focused on building maintainable, adaptive, and accessible iOS interfaces. Contains 45 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
Clinic Architecture Contract (iOS 26 / Swift 6.2)
All guidance in this skill assumes the clinic modular MVVM-C architecture:
- Feature modules import
Domain+DesignSystemonly (neverData, never sibling features) - App target is the convergence point and owns
DependencyContainer, concrete coordinators, and Route Shell wiring Domainstays pure Swift and defines models plus repository,*Coordinating,ErrorRouting, andAppErrorcontractsDataowns SwiftData/network/sync/retry/background I/O and implements Domain protocols- Read/write flow defaults to stale-while-revalidate reads and optimistic queued writes
- ViewModels call repository protocols directly (no default use-case/interactor layer)
When to Apply
Reference these guidelines when:
- Creating or modifying Storyboard scenes in Xcode Interface Builder
- Setting up Auto Layout constraints for adaptive layouts
- Designing navigation flows with segues and storyboard references
- Configuring size classes and trait variations for universal apps
- Reviewing storyboard XML diffs and resolving merge conflicts
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Storyboard Architecture | CRITICAL | arch- |
| 2 | Auto Layout Constraints | CRITICAL | layout- |
| 3 | Navigation & Segues | HIGH | nav- |
| 4 | Adaptive Layout & Size Classes | HIGH | adapt- |
| 5 | View Hierarchy & Stack Views | MEDIUM-HIGH | view- |
| 6 | Accessibility & VoiceOver | MEDIUM | ally- |
| 7 | Version Control & Collaboration | MEDIUM | vcs- |
| 8 | Debugging & Inspection | LOW-MEDIUM | debug- |
Quick Reference
1. Storyboard Architecture (CRITICAL)
- `arch-split-storyboards` - Split Monolithic Storyboards into Feature Modules
- `arch-storyboard-references` - Use Storyboard References for Cross-Module Navigation
- `arch-one-scene-per-flow` - Limit Each Storyboard to a Single User Flow
- `arch-initial-view-controller` - Set Initial View Controller Explicitly in Every Storyboard
- `arch-avoid-hardcoded-identifiers` - Avoid Hardcoded Storyboard and Cell Identifiers
- `arch-scene-naming` - Use Descriptive Scene Labels in Document Outline
- `arch-modular-xibs` - Extract Reusable Views into Separate XIB Files
2. Auto Layout Constraints (CRITICAL)
- `layout-avoid-fixed-dimensions` - Avoid Fixed Width and Height Constraints
- `layout-leading-trailing` - Use Leading and Trailing Instead of Left and Right
- `layout-safe-area` - Constrain Views to Safe Area Guides
- `layout-content-hugging` - Set Content Hugging and Compression Resistance Priorities
- `layout-constraint-nearest-neighbor` - Constrain to Nearest Neighbor Views
- `layout-avoid-constant-offsets` - Use Layout Margins Instead of Constant Offsets
- `layout-inequality-constraints` - Use Inequality Constraints for Flexible Minimums and Maximums
- `layout-constraint-priorities` - Assign Distinct Priorities to Optional Constraints
3. Navigation & Segues (HIGH)
- `nav-prepare-for-segue` - Pass Data via prepare(for:sender:) Instead of Direct Property Access
- `nav-unwind-segues` - Use Unwind Segues to Navigate Backward
- `nav-avoid-mixed-navigation` - Avoid Mixing Segue and Programmatic Navigation
- `nav-adaptive-segues` - Use Show and Show Detail Instead of Push and Modal
- `nav-perform-segue-validation` - Validate Segue Conditions with shouldPerformSegue
- `nav-container-view-controllers` - Use Container Views for Embedded Child View Controllers
4. Adaptive Layout & Size Classes (HIGH)
- `adapt-size-classes` - Configure Constraints per Size Class Using Vary for Traits
- `adapt-trait-variations` - Use Trait Variations for Font and Spacing Adjustments
- `adapt-safe-area-all-devices` - Test Adaptive Layouts on All Device Size Classes
- `adapt-readable-content-guide` - Use Readable Content Guide for Text on Large Screens
- `adapt-dynamic-type` - Support Dynamic Type for All Text Labels
5. View Hierarchy & Stack Views (MEDIUM-HIGH)
- `view-prefer-stack-views` - Use Stack Views Instead of Manual Constraints for Linear Layouts
- `view-avoid-deep-nesting` - Avoid Deeply Nested Stack Views Beyond Two Levels
- `view-intrinsic-content-size` - Rely on Intrinsic Content Size for Standard UIKit Controls
- `view-placeholder-intrinsic-size` - Use Placeholder Intrinsic Size for Custom Views in Storyboard
- `view-clip-to-bounds` - Enable Clip to Bounds for Views with Corner Radius
- `view-content-mode` - Set Correct Content Mode for UIImageView in Storyboard
6. Accessibility & VoiceOver (MEDIUM)
- `ally-labels` - Set Accessibility Labels for All Interactive Elements
- `ally-traits` - Assign Correct Accessibility Traits in Interface Builder
- `ally-grouping` - Group Related Elements for VoiceOver Navigation
- `ally-identifiers` - Set Accessibility Identifiers for UI Testing
- `ally-dynamic-labels` - Update Accessibility Labels for Dynamic Content
7. Version Control & Collaboration (MEDIUM)
- `vcs-one-scene-per-developer` - Assign Storyboard Scenes to Individual Developers
- `vcs-open-as-source` - Review Storyboard Diffs as Source Code Before Committing
- `vcs-lock-storyboard-files` - Use Git File Locking for Active Storyboard Edits
- `vcs-gitattributes-merge` - Configure .gitattributes to Use Union Merge for Storyboards
8. Debugging & Inspection (LOW-MEDIUM)
- `debug-view-hierarchy` - Use Debug View Hierarchy to Inspect Layout Issues
- `debug-ambiguous-layout` - Use hasAmbiguousLayout to Detect Constraint Problems at Runtime
- `debug-constraint-identifier` - Assign Identifiers to Constraints for Readable Logs
- `debug-stale-outlets` - Remove Stale Outlet Connections to Prevent Crashes
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Rule Title Here
Brief explanation (1-3 sentences) of WHY this matters. Focus on the performance, maintainability, or correctness implications.
Incorrect (description of the problem/cost):
// Bad code example here — production-realistic, not strawman
let badExample = problematicPattern()Correct (description of the benefit/solution):
// Good code example here — minimal diff from incorrect
let badExample = correctPattern()When NOT to use this pattern:
- Exception 1
- Exception 2
Benefits:
- Benefit 1
- Benefit 2
Reference: Reference Title
{
"version": "1.0.7",
"organization": "iOS Design Principles",
"technology": "Xcode Storyboard & Interface Builder (Legacy Interop in iOS 26 / Swift 6.2)",
"date": "February 2026",
"abstract": "Legacy interoperability guide for maintaining and migrating existing Xcode Storyboard / Interface Builder surfaces inside iOS 26 / Swift 6.2 clinic architecture projects. Use for storyboard maintenance, XML merge conflict resolution, and migration planning toward SwiftUI route-shell based features. Not intended for new SwiftUI clinic feature development.",
"references": [
"https://developer.apple.com/design/human-interface-guidelines",
"https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/AutolayoutPG/",
"https://developer.apple.com/library/archive/featuredarticles/ViewControllerPGforiPhoneOS/UsingSegues.html",
"https://developer.apple.com/documentation/uikit/uistackview",
"https://developer.apple.com/documentation/uikit/uiaccessibilitytraits",
"https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/AutolayoutPG/DebuggingTricksandTips.html",
"https://www.kodeco.com/5055364-ios-storyboards-getting-started",
"https://blog.supereasyapps.com/30-auto-layout-best-practices/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Storyboard Architecture (arch)
Impact: CRITICAL Description: Monolithic storyboards are the #1 cause of merge conflicts, slow Xcode loading, and tangled navigation. Splitting into modular storyboards with references yields the largest maintainability and team velocity gains.
2. Auto Layout Constraints (layout)
Impact: CRITICAL Description: Ambiguous or conflicting constraints cause runtime layout failures, unpredictable UI on different devices, and debugging nightmares. Correct constraint design eliminates entire classes of visual bugs.
3. Navigation & Segues (nav)
Impact: HIGH Description: Incorrect segue usage, missing unwind segues, and mixed navigation patterns cause crashes, memory leaks, and unmaintainable flow logic.
4. Adaptive Layout & Size Classes (adapt)
Impact: HIGH Description: Failing to use size classes and trait collections results in UIs that break on different devices and orientations, requiring device-specific workarounds.
5. View Hierarchy & Stack Views (view)
Impact: MEDIUM-HIGH Description: Deep nesting, missing stack views, and inefficient view hierarchies cause layout performance degradation and unnecessary constraint complexity.
6. Accessibility & VoiceOver (ally)
Impact: MEDIUM Description: Missing accessibility labels, traits, and identifiers exclude users with disabilities and fail App Store accessibility review requirements.
7. Version Control & Collaboration (vcs)
Impact: MEDIUM Description: Storyboard XML generates hard-to-merge conflicts. Proper structure and workflow prevent team-blocking merge hell.
8. Debugging & Inspection (debug)
Impact: LOW-MEDIUM Description: Inefficient debugging wastes hours. Knowing the right tools and techniques for constraint issues and view hierarchy inspection accelerates iteration.
Support Dynamic Type for All Text Labels
Fixed font sizes ignore the user's preferred content size category set in iOS Settings, making the app unusable for visually impaired users. Apple requires Dynamic Type support for accessibility compliance, and apps that fail to scale text properly receive lower App Store accessibility ratings.
Incorrect (hardcoded point sizes that never change with user preference):
<label id="eventTitle" text="Concert Details">
<fontDescription key="fontDescription" type="system" pointSize="17"/>
</label>
<label id="eventDate" text="March 15, 2025">
<fontDescription key="fontDescription" type="system" pointSize="13"/>
</label>
<label id="eventVenue" text="Royal Albert Hall">
<fontDescription key="fontDescription" type="system" pointSize="13"/>
</label>Correct (use text styles with adjustsFontForContentSizeCategory enabled):
<label id="eventTitle" text="Concert Details"
adjustsFontForContentSizeCategory="YES">
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
</label>
<label id="eventDate" text="March 15, 2025"
adjustsFontForContentSizeCategory="YES">
<fontDescription key="fontDescription" style="UICTFontTextStyleSubheadline"/>
</label>
<!-- numberOfLines=0 ensures text wraps when scaled up -->
<label id="eventVenue" text="Royal Albert Hall"
adjustsFontForContentSizeCategory="YES"
numberOfLines="0">
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
</label>For custom fonts, set the style in code with UIFontMetrics:
// EventDetailViewController.swift
eventTitle.font = UIFontMetrics(forTextStyle: .headline)
.scaledFont(for: UIFont(name: "Avenir-Heavy", size: 17)!)
eventTitle.adjustsFontForContentSizeCategory = trueBenefits:
- Text scales from Extra Small to Accessibility Extra Extra Extra Large (AX5) automatically
- Meets WCAG 2.1 AA text resizing requirements without custom zoom logic
- Setting
numberOfLines = 0on labels prevents truncation at larger accessibility sizes
Use Readable Content Guide for Text on Large Screens
Constraining text labels and text views to the superview's leading and trailing edges on iPad produces line lengths that exceed 100 characters, which drastically reduces readability. UIKit's readableContentGuide automatically narrows the content width to an optimal reading measure based on the current font size and screen width.
Incorrect (text spans full width of iPad screen):
<!-- Text stretches edge-to-edge on iPad Pro 12.9" — ~150 characters per line -->
<label id="articleBody" numberOfLines="0"
translatesAutoresizingMaskIntoConstraints="NO">
<constraints>
<constraint firstItem="articleBody" firstAttribute="leading"
secondItem="safeArea" secondAttribute="leading"
constant="16" id="body-lead"/>
<constraint firstItem="safeArea" firstAttribute="trailing"
secondItem="articleBody" secondAttribute="trailing"
constant="16" id="body-trail"/>
</constraints>
</label>Correct (constrain to readableContentGuide for optimal line length):
<!-- readableContentGuide caps line width to ~75 characters on iPad -->
<label id="articleBody" numberOfLines="0"
translatesAutoresizingMaskIntoConstraints="NO">
<constraints>
<constraint firstItem="articleBody" firstAttribute="leading"
secondItem="readableContentGuide" secondAttribute="leading"
id="body-lead"/>
<constraint firstItem="readableContentGuide" firstAttribute="trailing"
secondItem="articleBody" secondAttribute="trailing"
id="body-trail"/>
</constraints>
</label>In Interface Builder, select the constraint and check "Relative to margin" then switch the item from "Safe Area" to "Readable Content Guide" in the attribute inspector.
When NOT to use:
- Full-bleed images, maps, or media players should still pin to the superview or safe area edges
- Grid layouts where items fill the available width intentionally
Reference:
Test Adaptive Layouts on All Device Size Classes
Testing only on a single device (typically the developer's physical phone) hides layout breaks that surface on different screen sizes, orientations, and multitasking modes. Apple's review team tests on multiple devices, and constraint warnings or clipped content will trigger rejection.
Incorrect (testing only on the most common device):
// "It looks fine on my iPhone 15 Pro" — layout verified on one device only
//
// Untested scenarios:
// - iPhone SE (3rd gen): compact width, compact height in landscape
// - iPad Air in 1/3 Split View: compact width on a tablet
// - iPad Pro 12.9" full screen: regular width, regular height
// - iPhone 15 Pro Max landscape: compact heightCorrect (systematic testing across all four size class combinations):
// Device size class test matrix — verify each combination in Xcode previews or simulators
//
// ┌──────────────────────────────┬───────────────┬────────────────┐
// │ Device / Mode │ Width Class │ Height Class │
// ├──────────────────────────────┼───────────────┼────────────────┤
// │ iPhone SE portrait │ Compact │ Regular │
// │ iPhone SE landscape │ Compact │ Compact │
// │ iPhone 15 Pro Max portrait │ Compact │ Regular │
// │ iPhone 15 Pro Max landscape │ Regular │ Compact │
// │ iPad Air portrait │ Regular │ Regular │
// │ iPad Air landscape │ Regular │ Regular │
// │ iPad 1/3 Split View │ Compact │ Regular │
// │ iPad 2/3 Split View │ Regular │ Regular │
// │ iPad Slide Over │ Compact │ Regular │
// └──────────────────────────────┴───────────────┴────────────────┘
//
// Minimum coverage: test at least one device from each unique
// (widthClass, heightClass) pair — that is 4 combinations.Alternative:
Use Xcode's Preview canvas with multiple device configurations to verify layouts without launching simulators:
// In a SwiftUI preview host or UIViewControllerRepresentable wrapper
#Preview("Compact Width") {
let storyboard = UIStoryboard(name: "Products", bundle: nil)
return storyboard.instantiateInitialViewController()!
}Benefits:
- Catches truncated labels, overlapping views, and broken constraints before submission
- Reduces App Store review rejections caused by layout issues on untested devices
- Builds confidence that size class variations work as designed
Configure Constraints per Size Class Using Vary for Traits
Maintaining separate iPhone and iPad storyboards doubles the design surface, doubles the bug surface, and guarantees the two will drift out of sync. Interface Builder's "Vary for Traits" mode lets you install, uninstall, or change constraint constants per size class within a single storyboard file.
Incorrect (duplicate storyboards for each device family):
<!-- Main_iPhone.storyboard — must be kept in sync manually -->
<constraint firstItem="productImage" firstAttribute="width"
constant="120" id="img-w-iphone"/>
<constraint firstItem="productImage" firstAttribute="height"
constant="120" id="img-h-iphone"/>
<constraint firstItem="detailsStack" firstAttribute="leading"
secondItem="safeArea" secondAttribute="leading"
constant="16" id="stack-lead-iphone"/>
<!-- Main_iPad.storyboard — identical structure, different constants -->
<constraint firstItem="productImage" firstAttribute="width"
constant="280" id="img-w-ipad"/>
<constraint firstItem="productImage" firstAttribute="height"
constant="280" id="img-h-ipad"/>
<constraint firstItem="detailsStack" firstAttribute="leading"
secondItem="safeArea" secondAttribute="leading"
constant="40" id="stack-lead-ipad"/>Correct (single storyboard with size-class-specific constraint installations):
<!-- Products.storyboard — compact width (iPhone portrait) -->
<constraint firstItem="productImage" firstAttribute="width"
constant="120" id="img-w">
<variation key="widthClass=compact" constant="120"/>
<variation key="widthClass=regular" constant="280"/>
</constraint>
<constraint firstItem="productImage" firstAttribute="height"
constant="120" id="img-h">
<variation key="widthClass=compact" constant="120"/>
<variation key="widthClass=regular" constant="280"/>
</constraint>
<constraint firstItem="detailsStack" firstAttribute="leading"
secondItem="safeArea" secondAttribute="leading"
constant="16" id="stack-lead">
<variation key="widthClass=compact" constant="16"/>
<variation key="widthClass=regular" constant="40"/>
</constraint>Benefits:
- One storyboard to maintain instead of two, cutting design drift to zero
- Changes propagate to all device sizes automatically via trait variations
- Preview in Interface Builder by switching the simulated size class at the bottom bar
Use Trait Variations for Font and Spacing Adjustments
Hardcoding a single font size across all devices forces text to be either too small on iPad or too large on iPhone SE. Interface Builder's attribute variations let you set different font sizes, colors, and spacing values per size class, so typography adapts without a single line of code.
Incorrect (one font size used everywhere):
<!-- Same 14pt forced onto 4.7" iPhone and 12.9" iPad -->
<label id="articleTitle" text="Article Title">
<fontDescription key="fontDescription" type="system" pointSize="14"/>
</label>
<label id="articleBody" text="Lorem ipsum...">
<fontDescription key="fontDescription" type="system" pointSize="12"/>
</label>
<constraint firstItem="articleBody" firstAttribute="top"
secondItem="articleTitle" secondAttribute="bottom"
constant="8" id="title-body-spacing"/>Correct (trait-varied fonts and spacing that scale with device size):
<label id="articleTitle" text="Article Title">
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<variation key="widthClass=regular"
value="fontDescription.pointSize=20"/>
</label>
<label id="articleBody" text="Lorem ipsum...">
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<variation key="widthClass=regular"
value="fontDescription.pointSize=17"/>
</label>
<constraint firstItem="articleBody" firstAttribute="top"
secondItem="articleTitle" secondAttribute="bottom"
constant="8" id="title-body-spacing">
<variation key="widthClass=regular" constant="16"/>
</constraint>Benefits:
- Typography meets Apple HIG minimum tap target and readability guidelines on every device
- No runtime code needed; Interface Builder previews show both variants instantly
- Spacing adjusts proportionally, preventing cramped layouts on large screens
Update Accessibility Labels for Dynamic Content
Storyboards set accessibility labels at design time, but interactive controls like steppers, sliders, and toggle switches change their displayed value at runtime. If the accessibility label is never updated in code, VoiceOver announces the original static label regardless of the current state, leaving users unaware of what value they have selected.
Incorrect (static label ignores runtime value changes):
// CartItemCell.swift — configured in storyboard with label "Quantity stepper"
@IBOutlet weak var quantityStepper: UIStepper!
@IBOutlet weak var quantityLabel: UILabel!
@IBAction func stepperChanged(_ sender: UIStepper) {
let quantity = Int(sender.value)
quantityLabel.text = "\(quantity)"
// VoiceOver still announces "Quantity stepper" regardless of value
}Correct (accessibility label and value updated on every change):
// CartItemCell.swift
@IBOutlet weak var quantityStepper: UIStepper!
@IBOutlet weak var quantityLabel: UILabel!
@IBAction func stepperChanged(_ sender: UIStepper) {
let quantity = Int(sender.value)
quantityLabel.text = "\(quantity)"
sender.accessibilityLabel = "Quantity"
sender.accessibilityValue = "\(quantity) items"
UIAccessibility.post(notification: .layoutChanged, argument: sender)
}Use accessibilityValue for the changing portion and keep accessibilityLabel as the stable descriptor. VoiceOver announces both: "Quantity: 3 items, adjustable". Post a layout changed notification so VoiceOver re-reads the element immediately after the value changes.
Benefits:
- VoiceOver announces "Quantity: 3 items" instead of just "Quantity stepper"
- The
.layoutChangednotification ensures the new value is read without requiring a manual swipe - Separating label from value follows the UIAccessibility API contract
Reference: accessibilityValue
Group Related Elements for VoiceOver Navigation
A card showing a product name, price, rating, and review count forces VoiceOver users to swipe through 4-5 separate elements. Grouping these into a single accessible container with a combined label reduces the swipe count and provides the full context in one announcement, matching how sighted users perceive the card as a single unit.
Incorrect (5 separate accessible elements on a single card):
<view id="product-card">
<rect key="frame" x="16" y="100" width="343" height="120"/>
<subviews>
<imageView id="product-thumb" image="running-shoes">
<accessibility key="accessibilityConfiguration" label="Running shoes"/>
</imageView>
<label id="product-name" text="Air Zoom Pegasus 40"/>
<label id="product-price" text="$129.99"/>
<imageView id="star-icon" image="star.fill">
<accessibility key="accessibilityConfiguration" label="Rating"/>
</imageView>
<!-- VoiceOver: swipe, swipe, swipe, swipe to hear all info -->
<label id="rating-text" text="4.6 (892 reviews)"/>
</subviews>
</view>Correct (grouped container announced as a single element):
<view id="product-card"
shouldGroupAccessibilityChildren="YES">
<rect key="frame" x="16" y="100" width="343" height="120"/>
<accessibility key="accessibilityConfiguration"
isElement="YES"
label="Air Zoom Pegasus 40, $129.99, rated 4.6 out of 5, 892 reviews">
<accessibilityTraits key="traits" button="YES"/>
</accessibility>
<subviews>
<imageView id="product-thumb" image="running-shoes"/>
<label id="product-name" text="Air Zoom Pegasus 40"/>
<label id="product-price" text="$129.99"/>
<imageView id="star-icon" image="star.fill"/>
<label id="rating-text" text="4.6 (892 reviews)"/>
</subviews>
</view>For dynamic content where the label must be composed at runtime, set the combined label in code:
// ProductCardView.swift
override var accessibilityLabel: String? {
get {
return "\(product.name), \(product.formattedPrice), rated \(product.rating) out of 5, \(product.reviewCount) reviews"
}
set { }
}Reference: Grouping Accessibility Elements
Set Accessibility Identifiers for UI Testing
UI tests that locate elements by text content break when copy changes, when the app is localized, or when multiple elements share the same label. Accessibility identifiers are invisible to users but provide a stable, locale-independent hook for XCUITest queries. Setting them in Interface Builder's Identity Inspector ensures they are always present without requiring code changes.
Incorrect (UI test targeting element by visible text):
// CheckoutUITests.swift
func testApplyPromoCode() {
let app = XCUIApplication()
app.launch()
// Breaks when text is localized or copy changes
app.textFields["Enter promo code"].tap()
app.textFields["Enter promo code"].typeText("SAVE20")
app.buttons["Apply"].tap()
XCTAssertTrue(app.staticTexts["20% discount applied"].exists)
}Correct (UI test targeting element by stable accessibility identifier):
Set the identifier in the storyboard:
<textField id="promo-field" placeholder="Enter promo code">
<accessibility key="accessibilityConfiguration"
identifier="promo_code_field"/>
</textField>
<button id="apply-btn" buttonType="system">
<state key="normal" title="Apply"/>
<accessibility key="accessibilityConfiguration"
identifier="apply_promo_button"/>
</button>Then reference it in the test:
// CheckoutUITests.swift
func testApplyPromoCode() {
let app = XCUIApplication()
app.launch()
app.textFields["promo_code_field"].tap()
app.textFields["promo_code_field"].typeText("SAVE20")
app.buttons["apply_promo_button"].tap()
XCTAssertTrue(app.staticTexts["discount_confirmation_label"].exists)
}Benefits:
- Tests survive localization into any language
- Copy changes by the design team never break CI
- Identifiers serve as a contract between design and QA
Reference: accessibilityIdentifier
Set Accessibility Labels for All Interactive Elements
VoiceOver reads the accessibilityLabel to describe each element to users who cannot see the screen. Buttons with only an icon and no label are announced as "button" with no context. Form controls without labels force VoiceOver users to guess their purpose. Setting descriptive labels in Interface Builder's Identity Inspector ensures every interactive element is usable without sight.
Incorrect (icon-only buttons with no accessibility labels):
<button id="share-btn" buttonType="system">
<rect key="frame" x="320" y="16" width="44" height="44"/>
<state key="normal" image="square.and.arrow.up"/>
<!-- VoiceOver announces: "button" — no indication of purpose -->
</button>
<button id="favorite-btn" buttonType="system">
<rect key="frame" x="264" y="16" width="44" height="44"/>
<state key="normal" image="heart"/>
</button>Correct (descriptive accessibility labels set in Interface Builder):
<button id="share-btn" buttonType="system">
<rect key="frame" x="320" y="16" width="44" height="44"/>
<state key="normal" image="square.and.arrow.up"/>
<accessibility key="accessibilityConfiguration" label="Share this article"/>
</button>
<button id="favorite-btn" buttonType="system">
<rect key="frame" x="264" y="16" width="44" height="44"/>
<state key="normal" image="heart"/>
<accessibility key="accessibilityConfiguration" label="Add to favorites"/>
</button>Benefits:
- VoiceOver announces "Share this article, button" and "Add to favorites, button"
- Labels should describe the action, not the icon ("Share this article" not "Arrow box icon")
- Buttons with text titles automatically use the title as the label and do not need an explicit override
Reference: Accessibility Labels
Assign Correct Accessibility Traits in Interface Builder
Accessibility traits tell VoiceOver how an element behaves: whether it is a button, a link, a header, or a static text label. When a custom UIView acts as a button but lacks the button trait, VoiceOver announces it as a generic element and does not tell the user they can activate it. Incorrect traits mislead users about what interactions are available.
Incorrect (tappable custom view with no accessibility traits):
<view id="plan-card" customClass="PlanSelectionCard" customModule="Subscription">
<rect key="frame" x="16" y="200" width="343" height="120"/>
<accessibility key="accessibilityConfiguration"
label="Premium Plan - $9.99/month">
<!-- No traits: VoiceOver announces as static text, user doesn't know to tap -->
</accessibility>
<gestureRecognizers>
<tapGestureRecognizer id="plan-tap"/>
</gestureRecognizers>
</view>Correct (button trait tells VoiceOver this element is activatable):
<view id="plan-card" customClass="PlanSelectionCard" customModule="Subscription">
<rect key="frame" x="16" y="200" width="343" height="120"/>
<accessibility key="accessibilityConfiguration"
label="Premium Plan - $9.99/month">
<accessibilityTraits key="traits" button="YES"/>
</accessibility>
<gestureRecognizers>
<tapGestureRecognizer id="plan-tap"/>
</gestureRecognizers>
</view>Common trait assignments:
| Element Behavior | Trait | VoiceOver Effect |
|---|---|---|
| Tappable view or custom control | button | "Double-tap to activate" |
| Section title | header | Rotor header navigation |
| Tappable URL or deep link | link | "Double-tap to open link" |
| Image with no action | image | Announces as image |
| Frequently updating content | updatesFrequently | VoiceOver polls for changes |
Reference: UIAccessibilityTraits
Avoid Hardcoded Storyboard and Cell Identifiers
String literals for storyboard names, view controller identifiers, and cell reuse identifiers are invisible to the compiler. A single typo causes a runtime crash instead of a build error. Centralizing identifiers in enums or structs catches mistakes at compile time and provides autocomplete.
Incorrect (string literals scattered across the codebase):
final class OrderHistoryViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(
UINib(nibName: "OrderSummaryCell", bundle: nil),
forCellReuseIdentifier: "OrderSummaryCell"
)
}
func showOrderDetail(for order: Order) {
let storyboard = UIStoryboard(name: "Orders", bundle: nil)
// Typo: "OrderDetialVC" — crashes at runtime, no compiler warning
let detailVC = storyboard.instantiateViewController(
withIdentifier: "OrderDetialVC"
) as! OrderDetailViewController
detailVC.order = order
navigationController?.pushViewController(detailVC, animated: true)
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
-> UITableViewCell {
// Another copy of the string — must stay in sync manually
let cell = tableView.dequeueReusableCell(
withIdentifier: "OrderSummaryCell", for: indexPath
) as! OrderSummaryCell
return cell
}
}Correct (centralized identifier constants):
enum Storyboard: String {
case orders = "Orders"
case profile = "Profile"
case settings = "Settings"
var instance: UIStoryboard {
UIStoryboard(name: rawValue, bundle: nil)
}
}
enum ViewControllerID: String {
case orderDetail = "OrderDetailVC"
case orderHistory = "OrderHistoryVC"
case profileMain = "ProfileMainVC"
}
enum CellID: String {
case orderSummary = "OrderSummaryCell"
case profileHeader = "ProfileHeaderCell"
}final class OrderHistoryViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(
UINib(nibName: CellID.orderSummary.rawValue, bundle: nil),
forCellReuseIdentifier: CellID.orderSummary.rawValue
)
}
func showOrderDetail(for order: Order) {
let detailVC = Storyboard.orders.instance.instantiateViewController(
withIdentifier: ViewControllerID.orderDetail.rawValue
) as! OrderDetailViewController
detailVC.order = order
navigationController?.pushViewController(detailVC, animated: true)
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
-> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: CellID.orderSummary.rawValue, for: indexPath
) as! OrderSummaryCell
return cell
}
}Alternative (protocol-based approach for view controllers):
protocol StoryboardInstantiable: UIViewController {
static var storyboard: Storyboard { get }
static var identifier: String { get }
}
extension StoryboardInstantiable {
static var identifier: String { String(describing: self) }
static func instantiate() -> Self {
storyboard.instance.instantiateViewController(
withIdentifier: identifier
) as! Self
}
}
extension OrderDetailViewController: StoryboardInstantiable {
static var storyboard: Storyboard { .orders }
}
// Usage — compile-time safe, no string literals at call site
let detailVC = OrderDetailViewController.instantiate()Reference: UIStoryboard
Set Initial View Controller Explicitly in Every Storyboard
When a storyboard has no initialViewController attribute, calling instantiateInitialViewController() returns nil. Force-unwrapping that result -- or passing it to a navigation controller -- produces a crash with no useful error message. Every storyboard must declare exactly one initial view controller so that programmatic instantiation is always safe.
Incorrect (missing initialViewController attribute):
<!-- Checkout.storyboard — no initialViewController set -->
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB"
version="3.0" toolsVersion="21701" targetRuntime="AppleSDK">
<scenes>
<scene sceneID="checkout-cart">
<objects>
<viewController id="CheckoutCartVC"
sceneMemberID="viewController"/>
</objects>
</scene>
</scenes>
</document>// This crashes at runtime — instantiateInitialViewController() returns nil
let storyboard = UIStoryboard(name: "Checkout", bundle: nil)
let checkoutVC = storyboard.instantiateInitialViewController()!
// Fatal error: Unexpectedly found nil while unwrapping an Optional value
navigationController?.pushViewController(checkoutVC, animated: true)Correct (initialViewController attribute set on the document element):
<!-- Checkout.storyboard — initialViewController points to the entry scene -->
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB"
version="3.0" toolsVersion="21701" targetRuntime="AppleSDK"
initialViewController="CheckoutCartVC">
<scenes>
<scene sceneID="checkout-cart">
<objects>
<viewController id="CheckoutCartVC"
sceneMemberID="viewController"/>
</objects>
</scene>
</scenes>
</document>let storyboard = UIStoryboard(name: "Checkout", bundle: nil)
let checkoutVC = storyboard.instantiateInitialViewController()!
navigationController?.pushViewController(checkoutVC, animated: true)Alternative (safe unwrapping with guard):
let storyboard = UIStoryboard(name: "Checkout", bundle: nil)
guard let checkoutVC = storyboard.instantiateInitialViewController() else {
assertionFailure("Checkout.storyboard is missing its initial view controller")
return
}
navigationController?.pushViewController(checkoutVC, animated: true)Extract Reusable Views into Separate XIB Files
When the same custom view (a rating bar, a user avatar card, a branded input field) is copy-pasted into multiple storyboard scenes, every design change must be applied N times. Extracting the view into a standalone XIB with an @IBDesignable wrapper creates a single source of truth that renders live in every storyboard that uses it.
Incorrect (same view layout duplicated in every storyboard scene):
<!-- Checkout.storyboard — PaymentSummaryView layout inlined -->
<scene sceneID="checkout-confirm">
<objects>
<viewController id="OrderConfirmVC" sceneMemberID="viewController">
<view key="view">
<subviews>
<!-- Payment summary card — duplicated from Profile.storyboard -->
<view contentMode="scaleToFill" userLabel="PaymentSummaryView">
<subviews>
<label text="Total" />
<label text="$0.00" />
<imageView image="credit-card-icon" />
<label text="**** 4242" />
</subviews>
<color key="backgroundColor" red="0.96" green="0.96"
blue="0.97" alpha="1"/>
</view>
</subviews>
</view>
</viewController>
</objects>
</scene><!-- Profile.storyboard — identical PaymentSummaryView layout copied here -->
<scene sceneID="profile-billing">
<objects>
<viewController id="BillingInfoVC" sceneMemberID="viewController">
<view key="view">
<subviews>
<!-- Same layout, same constraints — must update both -->
<view contentMode="scaleToFill" userLabel="PaymentSummaryView">
<subviews>
<label text="Total" />
<label text="$0.00" />
<imageView image="credit-card-icon" />
<label text="**** 4242" />
</subviews>
<color key="backgroundColor" red="0.96" green="0.96"
blue="0.97" alpha="1"/>
</view>
</subviews>
</view>
</viewController>
</objects>
</scene>Correct (single XIB loaded via @IBDesignable):
@IBDesignable
final class PaymentSummaryView: UIView {
@IBOutlet private weak var totalLabel: UILabel!
@IBOutlet private weak var cardNumberLabel: UILabel!
@IBOutlet private weak var cardIconImageView: UIImageView!
@IBInspectable var totalAmount: String = "$0.00" {
didSet { totalLabel?.text = totalAmount }
}
@IBInspectable var maskedCardNumber: String = "**** 0000" {
didSet { cardNumberLabel?.text = maskedCardNumber }
}
override init(frame: CGRect) {
super.init(frame: frame)
loadNib()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
loadNib()
}
private func loadNib() {
let nib = UINib(nibName: "PaymentSummaryView", bundle: .init(for: Self.self))
guard let contentView = nib.instantiate(withOwner: self).first as? UIView else {
return
}
contentView.frame = bounds
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(contentView)
}
}<!-- Any storyboard — just drop in the custom class, renders live in IB -->
<scene sceneID="checkout-confirm">
<objects>
<viewController id="OrderConfirmVC" sceneMemberID="viewController">
<view key="view">
<subviews>
<view customClass="PaymentSummaryView"
customModule="ShopApp"
customModuleProvider="target"
contentMode="scaleToFill"
userLabel="Payment Summary">
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string"
keyPath="totalAmount" value="$129.99"/>
<userDefinedRuntimeAttribute type="string"
keyPath="maskedCardNumber" value="**** 4242"/>
</userDefinedRuntimeAttributes>
</view>
</subviews>
</view>
</viewController>
</objects>
</scene>When NOT to use:
- One-off views that appear in a single scene do not need XIB extraction. The overhead of a separate file and
@IBDesignableclass only pays off when the view appears in 2+ locations.
Benefits:
- Design changes propagate instantly to every storyboard scene
- XIB files are small and rarely cause merge conflicts
@IBDesignablerenders the view live in Interface Builder, so designers see real output
Limit Each Storyboard to a Single User Flow
When unrelated flows share a storyboard, every developer editing any of those flows contends for the same file. Xcode must also parse and render every scene in the storyboard when opening it, even if you only need to edit one screen. Keeping each storyboard focused on a single user flow (login, checkout, onboarding) eliminates unnecessary parsing and keeps ownership boundaries clear.
Incorrect (unrelated flows crammed into one storyboard):
<!-- Main.storyboard — three unrelated flows in a single file -->
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB"
version="3.0" toolsVersion="21701" targetRuntime="AppleSDK"
initialViewController="CheckoutCartVC">
<scenes>
<!-- Checkout flow -->
<scene sceneID="checkout-cart"><!-- CheckoutCartViewController --></scene>
<scene sceneID="checkout-shipping"><!-- ShippingAddressViewController --></scene>
<scene sceneID="checkout-payment"><!-- PaymentMethodViewController --></scene>
<scene sceneID="checkout-confirm"><!-- OrderConfirmationViewController --></scene>
<!-- Settings flow — unrelated to checkout -->
<scene sceneID="settings-main"><!-- SettingsViewController --></scene>
<scene sceneID="settings-notifications"><!-- NotificationPrefsViewController --></scene>
<scene sceneID="settings-privacy"><!-- PrivacySettingsViewController --></scene>
<!-- Onboarding flow — unrelated to both -->
<scene sceneID="onboarding-welcome"><!-- WelcomeViewController --></scene>
<scene sceneID="onboarding-permissions"><!-- PermissionsViewController --></scene>
<scene sceneID="onboarding-tutorial"><!-- TutorialViewController --></scene>
<scene sceneID="onboarding-complete"><!-- CompletionViewController --></scene>
</scenes>
</document>Correct (one storyboard per user flow):
<!-- Checkout.storyboard — only checkout scenes -->
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB"
version="3.0" toolsVersion="21701" targetRuntime="AppleSDK"
initialViewController="CheckoutCartVC">
<scenes>
<scene sceneID="checkout-cart"><!-- CheckoutCartViewController --></scene>
<scene sceneID="checkout-shipping"><!-- ShippingAddressViewController --></scene>
<scene sceneID="checkout-payment"><!-- PaymentMethodViewController --></scene>
<scene sceneID="checkout-confirm"><!-- OrderConfirmationViewController --></scene>
</scenes>
</document><!-- Settings.storyboard — only settings scenes -->
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB"
version="3.0" toolsVersion="21701" targetRuntime="AppleSDK"
initialViewController="SettingsMainVC">
<scenes>
<scene sceneID="settings-main"><!-- SettingsViewController --></scene>
<scene sceneID="settings-notifications"><!-- NotificationPrefsViewController --></scene>
<scene sceneID="settings-privacy"><!-- PrivacySettingsViewController --></scene>
</scenes>
</document><!-- Onboarding.storyboard — only onboarding scenes -->
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB"
version="3.0" toolsVersion="21701" targetRuntime="AppleSDK"
initialViewController="OnboardingWelcomeVC">
<scenes>
<scene sceneID="onboarding-welcome"><!-- WelcomeViewController --></scene>
<scene sceneID="onboarding-permissions"><!-- PermissionsViewController --></scene>
<scene sceneID="onboarding-tutorial"><!-- TutorialViewController --></scene>
<scene sceneID="onboarding-complete"><!-- CompletionViewController --></scene>
</scenes>
</document>When NOT to use:
- A trivial app with fewer than 5 screens total can use a single storyboard without significant cost. The overhead of splitting only pays off at scale (8+ screens or 2+ developers).
Use Descriptive Scene Labels in Document Outline
Xcode assigns every scene a default label of "View Controller Scene" in the Document Outline. When a storyboard has more than a few scenes, the outline becomes a wall of identical names, forcing developers to click through each one to find the right screen. Setting the userLabel attribute on each scene makes the outline instantly navigable.
Incorrect (default scene labels -- all identical):
<!-- Profile.storyboard — Document Outline shows: -->
<!-- "View Controller Scene" -->
<!-- "View Controller Scene" -->
<!-- "View Controller Scene" -->
<!-- "Table View Controller Scene" -->
<scenes>
<scene sceneID="abc-11">
<objects>
<viewController id="ProfileMainVC" sceneMemberID="viewController">
<!-- ... -->
</viewController>
</objects>
</scene>
<scene sceneID="abc-22">
<objects>
<viewController id="EditProfileVC" sceneMemberID="viewController">
<!-- ... -->
</viewController>
</objects>
</scene>
<scene sceneID="abc-33">
<objects>
<viewController id="ChangePasswordVC" sceneMemberID="viewController">
<!-- ... -->
</viewController>
</objects>
</scene>
<scene sceneID="abc-44">
<objects>
<tableViewController id="ActivityLogVC"
sceneMemberID="viewController">
<!-- ... -->
</tableViewController>
</objects>
</scene>
</scenes>Correct (descriptive userLabel on every scene):
<!-- Profile.storyboard — Document Outline shows: -->
<!-- "Profile Main Scene" -->
<!-- "Edit Profile Scene" -->
<!-- "Change Password Scene" -->
<!-- "Activity Log Scene" -->
<scenes>
<scene sceneID="abc-11" userLabel="Profile Main Scene">
<objects>
<viewController id="ProfileMainVC" sceneMemberID="viewController">
<!-- ... -->
</viewController>
</objects>
</scene>
<scene sceneID="abc-22" userLabel="Edit Profile Scene">
<objects>
<viewController id="EditProfileVC" sceneMemberID="viewController">
<!-- ... -->
</viewController>
</objects>
</scene>
<scene sceneID="abc-33" userLabel="Change Password Scene">
<objects>
<viewController id="ChangePasswordVC" sceneMemberID="viewController">
<!-- ... -->
</viewController>
</objects>
</scene>
<scene sceneID="abc-44" userLabel="Activity Log Scene">
<objects>
<tableViewController id="ActivityLogVC"
sceneMemberID="viewController">
<!-- ... -->
</tableViewController>
</objects>
</scene>
</scenes>Benefits:
- Developers locate scenes in under 2 seconds instead of clicking through each one
- Code review of storyboard XML diffs is immediately understandable
- Scene labels appear in Xcode's Find navigator, making project-wide search effective
Split Monolithic Storyboards into Feature Modules
A single Main.storyboard containing every screen in the app becomes unmergeable the moment two developers touch it simultaneously. Xcode serializes the entire scene graph into one XML file, so any concurrent edit produces hundreds of conflicting lines. Splitting by feature module isolates changes and cuts merge conflicts by 80% or more.
Incorrect (monolithic storyboard with all scenes in one file):
<!-- Main.storyboard — 18 scenes, edited by every developer on the team -->
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB"
version="3.0" toolsVersion="21701" targetRuntime="AppleSDK"
propertyAccessControl="none" useAutolayout="YES"
useTraitCollections="YES" useSafeAreas="YES"
colorMatched="YES"
initialViewController="LaunchScreenVC">
<scenes>
<!-- Login flow -->
<scene sceneID="login-1"><!-- ... --></scene>
<scene sceneID="login-2"><!-- ... --></scene>
<scene sceneID="login-3"><!-- ... --></scene>
<!-- Profile flow -->
<scene sceneID="profile-1"><!-- ... --></scene>
<scene sceneID="profile-2"><!-- ... --></scene>
<scene sceneID="profile-3"><!-- ... --></scene>
<!-- Settings flow -->
<scene sceneID="settings-1"><!-- ... --></scene>
<scene sceneID="settings-2"><!-- ... --></scene>
<scene sceneID="settings-3"><!-- ... --></scene>
<!-- Checkout flow -->
<scene sceneID="checkout-1"><!-- ... --></scene>
<scene sceneID="checkout-2"><!-- ... --></scene>
<scene sceneID="checkout-3"><!-- ... --></scene>
<scene sceneID="checkout-4"><!-- ... --></scene>
<!-- Onboarding flow -->
<scene sceneID="onboarding-1"><!-- ... --></scene>
<scene sceneID="onboarding-2"><!-- ... --></scene>
<scene sceneID="onboarding-3"><!-- ... --></scene>
<scene sceneID="onboarding-4"><!-- ... --></scene>
<scene sceneID="onboarding-5"><!-- ... --></scene>
</scenes>
</document>Correct (separate storyboard per feature module):
<!-- Login.storyboard — 3 scenes, owned by the auth team -->
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB"
version="3.0" toolsVersion="21701" targetRuntime="AppleSDK"
propertyAccessControl="none" useAutolayout="YES"
useTraitCollections="YES" useSafeAreas="YES"
colorMatched="YES"
initialViewController="LoginVC">
<scenes>
<scene sceneID="login-1"><!-- ... --></scene>
<scene sceneID="login-2"><!-- ... --></scene>
<scene sceneID="login-3"><!-- ... --></scene>
</scenes>
</document><!-- Profile.storyboard — 3 scenes -->
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB"
version="3.0" toolsVersion="21701" targetRuntime="AppleSDK"
propertyAccessControl="none" useAutolayout="YES"
useTraitCollections="YES" useSafeAreas="YES"
colorMatched="YES"
initialViewController="ProfileVC">
<scenes>
<scene sceneID="profile-1"><!-- ... --></scene>
<scene sceneID="profile-2"><!-- ... --></scene>
<scene sceneID="profile-3"><!-- ... --></scene>
</scenes>
</document><!-- Settings.storyboard — 3 scenes -->
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB"
version="3.0" toolsVersion="21701" targetRuntime="AppleSDK"
propertyAccessControl="none" useAutolayout="YES"
useTraitCollections="YES" useSafeAreas="YES"
colorMatched="YES"
initialViewController="SettingsVC">
<scenes>
<scene sceneID="settings-1"><!-- ... --></scene>
<scene sceneID="settings-2"><!-- ... --></scene>
<scene sceneID="settings-3"><!-- ... --></scene>
</scenes>
</document>Benefits:
- Each feature storyboard has 3-5 scenes max, making git diffs readable
- Developers working on different features never touch the same file
- Xcode opens and renders small storyboards instantly
- Feature teams can own their storyboard files via CODEOWNERS
Use Storyboard References for Cross-Module Navigation
Hardcoded storyboard instantiation in Swift creates tight coupling between feature modules. If the destination storyboard is renamed or restructured, every call site breaks silently at runtime. Storyboard references declared in Interface Builder let Xcode validate the connection at build time and keep navigation wiring inside the storyboard where it belongs.
Incorrect (hardcoded storyboard instantiation in Swift):
final class ProfileViewController: UIViewController {
@IBAction func openSettingsTapped(_ sender: UIButton) {
// Hardcoded storyboard name — crashes if renamed
let settingsStoryboard = UIStoryboard(name: "Settings", bundle: nil)
let settingsVC = settingsStoryboard.instantiateViewController(
withIdentifier: "SettingsMainVC"
)
navigationController?.pushViewController(settingsVC, animated: true)
}
}<!-- Profile.storyboard — no reference to Settings, navigation is invisible to IB -->
<scene sceneID="profile-main">
<objects>
<viewController id="ProfileVC" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill">
<subviews>
<button opaque="NO" contentMode="scaleToFill"
title="Settings">
<connections>
<action selector="openSettingsTapped:"
destination="ProfileVC" eventType="touchUpInside"/>
</connections>
</button>
</subviews>
</view>
</viewController>
</objects>
</scene>Correct (storyboard reference wired in Interface Builder):
<!-- Profile.storyboard — navigation is visible and validated by Xcode -->
<scene sceneID="profile-main">
<objects>
<viewController id="ProfileVC" sceneMemberID="viewController">
<connections>
<segue destination="settingsRef"
kind="show" identifier="showSettings"/>
</connections>
</viewController>
</objects>
</scene>
<!-- Storyboard reference — Xcode validates this at build time -->
<scene sceneID="settings-ref-scene">
<objects>
<storyboardReference id="settingsRef"
storyboardName="Settings"
referencedIdentifier="SettingsMainVC"/>
</objects>
</scene>final class ProfileViewController: UIViewController {
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "showSettings",
let settingsVC = segue.destination as? SettingsViewController {
settingsVC.userProfile = currentProfile
}
}
}Benefits:
- Xcode warns at build time if the referenced storyboard or identifier is missing
- Navigation flow is visible in Interface Builder's canvas
- Feature modules only need to agree on entry-point identifiers, not internal structure
Use hasAmbiguousLayout to Detect Constraint Problems at Runtime
Ambiguous layouts do not crash the app -- they silently pick an arbitrary frame, which means a view may appear correct on one device but shift unpredictably on another. Without an explicit runtime check, these bugs only surface when a user on a different screen size reports misaligned UI.
Incorrect (no runtime detection of ambiguous constraints):
class OrderSummaryViewController: UIViewController {
@IBOutlet weak var totalLabel: UILabel!
@IBOutlet weak var itemCountLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
totalLabel.text = "$129.99"
itemCountLabel.text = "3 items"
// No check for ambiguous layout; views may render in wrong positions
// and the issue goes unnoticed until a tester files a bug
}
}Correct (assert on ambiguous layout in debug builds):
class OrderSummaryViewController: UIViewController {
@IBOutlet weak var totalLabel: UILabel!
@IBOutlet weak var itemCountLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
totalLabel.text = "$129.99"
itemCountLabel.text = "3 items"
}
#if DEBUG
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
for subview in view.subviews where subview.hasAmbiguousLayout {
// Logs the view and animates it between valid positions
print("AMBIGUOUS LAYOUT: \(subview)")
subview.exerciseAmbiguityInLayout()
}
}
#endif
}Alternative (recursive check for deep view hierarchies):
#if DEBUG
extension UIView {
func reportAmbiguousLayouts(depth: Int = 0) {
if hasAmbiguousLayout {
let indent = String(repeating: " ", count: depth)
print("\(indent)AMBIGUOUS: \(type(of: self)), frame: \(frame)")
}
subviews.forEach { $0.reportAmbiguousLayouts(depth: depth + 1) }
}
}
#endifBenefits:
- Surfaces ambiguous layouts immediately during development, not after release
exerciseAmbiguityInLayout()visually animates between valid positions, making the problem obvious#if DEBUGensures zero overhead in production builds
Reference: hasAmbiguousLayout
Assign Identifiers to Constraints for Readable Logs
When Auto Layout cannot satisfy constraints, it prints every conflicting constraint using memory addresses and generic type names. In a complex storyboard scene with dozens of constraints, matching NSLayoutConstraint:0x600003a1c230 to the actual view requires tedious LLDB inspection. Named identifiers make the error log immediately actionable.
Incorrect (constraints without identifiers produce unreadable logs):
<!-- ProductDetail.storyboard -->
<constraints>
<constraint firstItem="product-image" firstAttribute="top"
secondItem="safe-area" secondAttribute="top" constant="16"/>
<constraint firstItem="product-title" firstAttribute="top"
secondItem="product-image" secondAttribute="bottom" constant="12"/>
<constraint firstItem="product-price" firstAttribute="top"
secondItem="product-title" secondAttribute="bottom" constant="8"/>
</constraints>
<!-- Runtime error log — which constraint is "0x600003a1c230"? -->
<!--
Unable to simultaneously satisfy constraints.
(
"<NSLayoutConstraint:0x600003a1c230 V:|-(16)-[UIImageView:0x7fa3b2d08e00]>",
"<NSLayoutConstraint:0x600003a1c2d0 V:[UIImageView:0x7fa3b2d08e00]-(12)-[UILabel:0x7fa3b2d09a20]>",
"<NSLayoutConstraint:0x600003a1c370 V:[UILabel:0x7fa3b2d09a20]-(8)-[UILabel:0x7fa3b2d0a640]>"
)
-->Correct (named identifiers produce self-documenting logs):
<!-- ProductDetail.storyboard -->
<constraints>
<constraint identifier="productImage-top-to-safeArea"
firstItem="product-image" firstAttribute="top"
secondItem="safe-area" secondAttribute="top" constant="16"/>
<constraint identifier="productTitle-top-to-image"
firstItem="product-title" firstAttribute="top"
secondItem="product-image" secondAttribute="bottom" constant="12"/>
<constraint identifier="productPrice-top-to-title"
firstItem="product-price" firstAttribute="top"
secondItem="product-title" secondAttribute="bottom" constant="8"/>
</constraints>
<!-- Runtime error log — immediately identifies the problematic constraint -->
<!--
Unable to simultaneously satisfy constraints.
(
"<NSLayoutConstraint:0x600003a1c230 'productImage-top-to-safeArea' V:|-(16)-[UIImageView:0x7fa3b2d08e00]>",
"<NSLayoutConstraint:0x600003a1c2d0 'productTitle-top-to-image' V:[UIImageView:0x7fa3b2d08e00]-(12)-[UILabel:0x7fa3b2d09a20]>",
"<NSLayoutConstraint:0x600003a1c370 'productPrice-top-to-title' V:[UILabel:0x7fa3b2d09a20]-(8)-[UILabel:0x7fa3b2d0a640]>"
)
-->Alternative (assign identifiers at runtime for programmatic constraints):
let imageTopConstraint = productImageView.topAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16
)
imageTopConstraint.identifier = "productImage-top-to-safeArea"Benefits:
- Constraint names appear directly in Xcode console logs and the Debug View Hierarchy inspector
- Enables grep-based searching through logs for specific constraint families
- No runtime performance cost -- identifiers are debug metadata only
Reference: NSLayoutConstraint.identifier
Remove Stale Outlet Connections to Prevent Crashes
When you rename or delete an @IBOutlet property in code but forget to disconnect it in the storyboard, the storyboard XML still references the old key name. At runtime, setValue:forUndefinedKey: throws NSUnknownKeyException, crashing the app on launch of that view controller. This crash has no compile-time warning and only manifests at runtime.
Incorrect (deleted IBOutlet still connected in storyboard causes crash):
// ProfileViewController.swift — "headerImageView" was renamed to "avatarImageView"
class ProfileViewController: UIViewController {
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var displayNameLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
avatarImageView.layer.cornerRadius = 40
displayNameLabel.text = "Jane Doe"
}
}
// CRASH at runtime:
// *** Terminating app due to uncaught exception 'NSUnknownKeyException',
// reason: '[<ProfileViewController 0x7fa3b2d08e00> setValue:forUndefinedKey:]:
// this class is not key value coding-compliant for the key headerImageView.'Correct (verify outlet connections after renaming or deleting properties):
// ProfileViewController.swift
class ProfileViewController: UIViewController {
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var displayNameLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
avatarImageView.layer.cornerRadius = 40
displayNameLabel.text = "Jane Doe"
}
}
// After renaming: open Profile.storyboard, select ProfileViewController scene,
// open Connections Inspector (Cmd+6), and:
// 1. Remove the stale "headerImageView" connection (yellow warning triangle)
// 2. Reconnect the view to the new "avatarImageView" outletWhen NOT to use:
- If you use
@IBOutletexclusively with XIBs and never storyboards, the same issue applies but the cleanup happens in the XIB file instead.
Benefits:
- Eliminates a crash that only surfaces at runtime with no compiler warning
- Connections Inspector shows yellow warning icons for stale outlets, making them easy to spot
- Adopting a habit of checking Cmd+6 after every outlet rename prevents regressions
Reference: Connecting Objects to Code
Use Debug View Hierarchy to Inspect Layout Issues
Scrolling through Auto Layout constraint error logs in the console forces you to mentally reconstruct the view tree from memory addresses, which is error-prone and slow. Xcode's Debug View Hierarchy renders a 3D-explorable model of the live view tree, highlighting conflicting or ambiguous constraints in place and letting you inspect every frame, constraint, and priority visually.
Incorrect (reading raw constraint logs in the console):
// Attempting to debug layout by parsing console output
// Console prints walls of text like:
// Unable to simultaneously satisfy constraints.
// (
// "<NSLayoutConstraint:0x600003a1c230 UILabel:0x7fa3b2d08e00.top == UIView:0x7fa3b2d04a10.top + 16>",
// "<NSLayoutConstraint:0x600003a1c2d0 UILabel:0x7fa3b2d08e00.top == UIView:0x7fa3b2d04a10.top + 24>",
// ...
// )
// Developer tries to match memory addresses manually to find the broken view
func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
for subview in view.subviews {
print("View: \(subview), frame: \(subview.frame)")
}
}Correct (use Debug View Hierarchy for visual constraint inspection):
// 1. Run the app on simulator or device
// 2. Xcode menu: Debug > View Debugging > Capture View Hierarchy
// 3. In the 3D view, click any view to see its constraints in the inspector
// 4. Purple constraint indicators show conflicts directly on the view
// For programmatic inspection in LLDB, pause execution and run:
// (lldb) expr -l objc -- [[UIWindow keyWindow] _autolayoutTrace]
// (lldb) expr -l objc -- [0x7fa3b2d08e00 _constraintsAffectingLayoutForAxis:0]When NOT to use:
- For constraints created purely in code with no storyboard involvement, breakpoint-based LLDB inspection may be faster than capturing the full hierarchy.
Benefits:
- Visually pinpoints the exact view causing constraint conflicts without decoding memory addresses
- Shows clipped or overlapping views that are invisible in the running app
- Displays constraint priorities and installed size class variants inline
Reference: Debugging Auto Layout
Use Layout Margins Instead of Constant Offsets
Hardcoding constant values (e.g., 16, 20) to superview edges creates inconsistent spacing that does not adapt to device size class or user accessibility settings. The system layoutMarginsGuide provides standard insets that adjust per device (16pt on iPhone SE, 20pt on larger iPhones) and respect the readable content guide for wide screens like iPad.
Incorrect (hardcoded constant to superview edge):
<constraints>
<constraint firstItem="article-body" firstAttribute="leading"
secondItem="root-view" secondAttribute="leading" constant="16"/>
<constraint firstItem="root-view" firstAttribute="trailing"
secondItem="article-body" secondAttribute="trailing" constant="16"/>
<!-- Constant stays 16pt on all devices, too narrow on iPad -->
</constraints>Correct (constraint to layout margins guide):
<view id="root-view">
<viewLayoutGuide key="layoutMargins" id="margins-guide"/>
<subviews>
<textView id="article-body"/>
</subviews>
<constraints>
<constraint firstItem="article-body" firstAttribute="leading"
secondItem="margins-guide" secondAttribute="leading"/>
<constraint firstItem="margins-guide" firstAttribute="trailing"
secondItem="article-body" secondAttribute="trailing"/>
</constraints>
</view>Alternative (readable content guide for text-heavy layouts):
<viewLayoutGuide key="readableContentGuide" id="readable-guide"/>
<constraints>
<constraint firstItem="article-body" firstAttribute="leading"
secondItem="readable-guide" secondAttribute="leading"/>
<constraint firstItem="readable-guide" firstAttribute="trailing"
secondItem="article-body" secondAttribute="trailing"/>
</constraints>Reference: UIView.layoutMarginsGuide
Avoid Fixed Width and Height Constraints
Fixed width and height constraints force views to a single pixel size, causing truncation on smaller screens and wasted space on larger ones. Views with intrinsic content size (labels, buttons, images) should size themselves based on content and be positioned with edge constraints instead.
Incorrect (fixed width constraint on a label):
<label id="greeting-label" text="Welcome back, Alexander">
<rect key="frame" x="87" y="200" width="200" height="21"/>
<constraints>
<!-- Forces label to exactly 200pt regardless of content or screen -->
<constraint firstAttribute="width" constant="200"/>
</constraints>
</label>Correct (leading and trailing constraints with intrinsic content size):
<label id="greeting-label" text="Welcome back, Alexander">
<rect key="frame" x="16" y="200" width="343" height="21"/>
<constraints>
<constraint firstItem="greeting-label" firstAttribute="leading"
secondItem="content-view" secondAttribute="leading" constant="16"/>
<constraint firstItem="content-view" firstAttribute="trailing"
secondItem="greeting-label" secondAttribute="trailing" constant="16"/>
</constraints>
</label>When NOT to use intrinsic sizing:
- Fixed-size icons or avatars (e.g., a 44x44pt tap target)
- Separator lines requiring a 1pt or 0.5pt height
- Views without intrinsic content size that need explicit dimensions
Reference: Auto Layout Guide - Intrinsic Content Size
Constrain to Nearest Neighbor Views
Constraints should reference the nearest sibling or the immediate container rather than distant views in the hierarchy. When a view is constrained to a non-adjacent sibling, reordering or removing an intermediate view breaks the layout chain silently, requiring a full constraint audit to diagnose.
Incorrect (view C constrained to distant view A, skipping view B):
<subviews>
<label id="section-header" text="Payment Method"/>
<imageView id="card-icon" image="credit-card"/>
<label id="card-number" text="**** **** **** 4242"/>
</subviews>
<constraints>
<constraint firstItem="section-header" firstAttribute="top"
secondItem="safe-area" secondAttribute="top" constant="16"/>
<constraint firstItem="card-icon" firstAttribute="top"
secondItem="section-header" secondAttribute="bottom" constant="12"/>
<!-- Skips card-icon, couples card-number to section-header -->
<constraint firstItem="card-number" firstAttribute="top"
secondItem="section-header" secondAttribute="bottom" constant="56"/>
</constraints>Correct (each view constrained to its immediate neighbor):
<subviews>
<label id="section-header" text="Payment Method"/>
<imageView id="card-icon" image="credit-card"/>
<label id="card-number" text="**** **** **** 4242"/>
</subviews>
<constraints>
<constraint firstItem="section-header" firstAttribute="top"
secondItem="safe-area" secondAttribute="top" constant="16"/>
<constraint firstItem="card-icon" firstAttribute="top"
secondItem="section-header" secondAttribute="bottom" constant="12"/>
<constraint firstItem="card-number" firstAttribute="top"
secondItem="card-icon" secondAttribute="bottom" constant="8"/>
</constraints>Benefits:
- Inserting, removing, or reordering views only requires updating adjacent constraints
- Constraint constants reflect actual visual spacing, not cumulative offsets
- Easier to debug in Xcode's constraint inspector
Reference: Auto Layout Guide - Anatomy of a Constraint
Assign Distinct Priorities to Optional Constraints
When Auto Layout cannot satisfy all constraints, it breaks the constraint with the lowest priority. If two optional constraints share the same priority, the engine picks one arbitrarily, producing different results across devices or OS versions. Assigning distinct priorities makes constraint breaking deterministic and debuggable.
Incorrect (two optional constraints with identical priority):
<label id="subtitle-label" text="Premium Member Since 2019">
<rect key="frame" x="16" y="60" width="343" height="21"/>
<constraints>
<!-- Both priority=750: Auto Layout picks one to break at random -->
<constraint firstItem="subtitle-label" firstAttribute="trailing"
secondItem="safe-area" secondAttribute="trailing"
constant="16" priority="750"/>
<constraint firstItem="subtitle-label" firstAttribute="width"
relation="greaterThanOrEqual" constant="280" priority="750"/>
</constraints>
</label>Correct (distinct priorities so breaking order is predictable):
<label id="subtitle-label" text="Premium Member Since 2019">
<rect key="frame" x="16" y="60" width="343" height="21"/>
<constraints>
<!-- Priority 750: preferred trailing alignment -->
<constraint firstItem="subtitle-label" firstAttribute="trailing"
secondItem="safe-area" secondAttribute="trailing"
constant="16" priority="750"/>
<!-- Priority 749: breaks first, allowing trailing to win -->
<constraint firstItem="subtitle-label" firstAttribute="width"
relation="greaterThanOrEqual" constant="280" priority="749"/>
</constraints>
</label>Benefits:
- Console logs clearly identify which constraint was broken and why
- Behavior is identical across all devices and OS versions
- Easier to tune layout trade-offs by adjusting a single priority value
Reference: Auto Layout Guide - Constraint Priorities
Set Content Hugging and Compression Resistance Priorities
When two views with intrinsic content size share the same axis and space is insufficient or excessive, Auto Layout must decide which view stretches or compresses. With default priorities (hugging=250, compression resistance=750), the layout is ambiguous and Auto Layout picks arbitrarily, producing Xcode warnings and inconsistent results across OS versions.
Incorrect (both labels use default priorities, layout is ambiguous):
<label id="field-label" text="Email Address">
<rect key="frame" x="16" y="100" width="120" height="21"/>
<!-- Default contentHuggingPriority horizontal=250 -->
<!-- Default contentCompressionResistancePriority horizontal=750 -->
</label>
<label id="field-value" text="user@example.com">
<rect key="frame" x="144" y="100" width="200" height="21"/>
<!-- Same defaults: Auto Layout cannot decide which label stretches -->
</label>Correct (explicit priorities resolve ambiguity):
<label id="field-label" text="Email Address">
<rect key="frame" x="16" y="100" width="120" height="21"/>
<contentHuggingPriority key="horizontal" value="252"/>
<contentCompressionResistancePriority key="horizontal" value="751"/>
</label>
<label id="field-value" text="user@example.com">
<rect key="frame" x="144" y="100" width="200" height="21"/>
<!-- Keeps defaults (hugging=250): this label stretches to fill space -->
</label>Alternative (set in code for dynamic cases):
fieldLabel.setContentHuggingPriority(.defaultLow + 2, for: .horizontal)
fieldLabel.setContentCompressionResistancePriority(.defaultHigh + 1, for: .horizontal)Reference: Auto Layout Guide - Intrinsic Content Size
Use Inequality Constraints for Flexible Minimums and Maximums
Fixed-value constraints force a single size that may not fit all screen widths. Inequality constraints (greaterThanOrEqual, lessThanOrEqual) define flexible bounds, allowing views to grow or shrink within acceptable limits while preventing content from being truncated or stretching beyond a readable width.
Incorrect (fixed width that truncates on iPhone SE):
<button id="checkout-button" title="Proceed to Checkout">
<rect key="frame" x="60" y="500" width="300" height="50"/>
<constraints>
<!-- Fixed 300pt overflows on 320pt-wide iPhone SE -->
<constraint firstAttribute="width" constant="300"/>
</constraints>
</button>Correct (inequality constraints with flexible range):
<button id="checkout-button" title="Proceed to Checkout">
<rect key="frame" x="60" y="500" width="300" height="50"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="200"/>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="400"/>
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="44"/>
</constraints>
</button>When NOT to use inequality constraints:
- When a view must be an exact size (e.g., a square avatar thumbnail)
- When leading/trailing constraints already provide the flexibility needed
Reference: Auto Layout Guide - Anatomy of a Constraint
Use Leading and Trailing Instead of Left and Right
Leading and trailing attributes automatically flip in right-to-left (RTL) languages such as Arabic, Hebrew, and Urdu. Using left and right hardcodes the layout direction, breaking the UI for over 30 RTL languages representing hundreds of millions of users.
Incorrect (hardcoded left and right attributes):
<constraints>
<!-- Left/Right ignores RTL languages entirely -->
<constraint firstItem="profile-image" firstAttribute="left"
secondItem="cell-content" secondAttribute="left" constant="16"/>
<constraint firstItem="username-label" firstAttribute="left"
secondItem="profile-image" secondAttribute="right" constant="12"/>
<constraint firstItem="cell-content" firstAttribute="right"
secondItem="timestamp-label" secondAttribute="right" constant="16"/>
</constraints>Correct (leading and trailing attributes):
<constraints>
<constraint firstItem="profile-image" firstAttribute="leading"
secondItem="cell-content" secondAttribute="leading" constant="16"/>
<constraint firstItem="username-label" firstAttribute="leading"
secondItem="profile-image" secondAttribute="trailing" constant="12"/>
<constraint firstItem="cell-content" firstAttribute="trailing"
secondItem="timestamp-label" secondAttribute="trailing" constant="16"/>
</constraints>When NOT to use leading/trailing:
- Constraints tied to a physical screen edge regardless of language direction (e.g., a camera viewfinder overlay that must stay on the literal left side)
Reference: Auto Layout Guide - Working with Constraints in Interface Builder
Constrain Views to Safe Area Guides
The safe area insets account for the status bar, navigation bar, notch, Dynamic Island, and home indicator. Constraining to the superview edges instead of the safe area causes content to render behind system UI elements, making it untappable or invisible.
Incorrect (constraints to superview edges):
<viewController id="main-vc">
<view key="view" id="root-view">
<subviews>
<label id="page-title" text="Account Settings">
<rect key="frame" x="16" y="0" width="343" height="44"/>
</label>
</subviews>
<constraints>
<!-- Top constraint to superview hides label behind notch/Dynamic Island -->
<constraint firstItem="page-title" firstAttribute="top"
secondItem="root-view" secondAttribute="top"/>
<constraint firstItem="page-title" firstAttribute="leading"
secondItem="root-view" secondAttribute="leading" constant="16"/>
</constraints>
</view>
</viewController>Correct (constraints to safe area layout guide):
<viewController id="main-vc">
<view key="view" id="root-view">
<viewLayoutGuide key="safeArea" id="safe-area"/>
<subviews>
<label id="page-title" text="Account Settings">
<rect key="frame" x="16" y="59" width="343" height="44"/>
</label>
</subviews>
<constraints>
<constraint firstItem="page-title" firstAttribute="top"
secondItem="safe-area" secondAttribute="top"/>
<constraint firstItem="page-title" firstAttribute="leading"
secondItem="safe-area" secondAttribute="leading" constant="16"/>
</constraints>
</view>
</viewController>Benefits:
- Automatically adapts to all device form factors (iPhone SE through Pro Max)
- Handles landscape orientation where safe area insets shift
- Future-proofs against new hardware shapes
Use Show and Show Detail Instead of Push and Modal
The legacy Push and Modal segue types hard-code a single presentation style regardless of device or size class. The adaptive Show and Show Detail segue types let UIKit choose the appropriate presentation automatically: pushing onto a navigation stack on iPhone, or replacing the detail pane in a split view on iPad.
Incorrect (legacy push segue that breaks on iPad split view):
<!-- Hard-coded push ignores split view context on iPad -->
<segue destination="ArticleDetailVC" kind="push"
identifier="ShowArticle" id="seg-001"/>
<segue destination="ComposeVC" kind="modal"
identifier="ComposeMessage"
modalPresentationStyle="fullScreen" id="seg-002"/>Correct (adaptive segue types that adjust to the device context):
<segue destination="ArticleDetailVC" kind="show"
identifier="ShowArticle" id="seg-001"/>
<!-- showDetail replaces the detail pane on iPad, pushes on iPhone -->
<segue destination="ComposeVC" kind="showDetail"
identifier="ComposeMessage" id="seg-002"/>When NOT to use:
- Use Present Modally when you explicitly need a modal sheet that should never push (e.g., a login gate or camera capture flow)
Reference:
Avoid Mixing Segue and Programmatic Navigation
When a flow uses storyboard segues for forward navigation but programmatic calls for backward navigation (or vice versa), the navigation graph in Interface Builder becomes incomplete and misleading. Developers cannot trace the full flow visually, and UIKit may handle animations and lifecycle events differently between the two approaches.
Incorrect (segues for push, manual code for pop within the same flow):
// RecipeListViewController.swift
// Forward: uses storyboard segue
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "ShowRecipeDetail",
let detailVC = segue.destination as? RecipeDetailViewController {
detailVC.recipe = selectedRecipe
}
}
// RecipeDetailViewController.swift
@IBAction func editRecipe(_ sender: UIButton) {
// Forward: switches to programmatic navigation mid-flow
let storyboard = UIStoryboard(name: "Recipes", bundle: nil)
let editVC = storyboard.instantiateViewController(
withIdentifier: "RecipeEditViewController"
) as! RecipeEditViewController
editVC.recipe = recipe
navigationController?.pushViewController(editVC, animated: true)
}Correct (consistent segue-based navigation throughout the flow):
// RecipeListViewController.swift
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "ShowRecipeDetail",
let detailVC = segue.destination as? RecipeDetailViewController {
detailVC.recipe = selectedRecipe
}
}
// RecipeDetailViewController.swift
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "EditRecipe",
let editVC = segue.destination as? RecipeEditViewController {
editVC.recipe = recipe
}
}
// RecipeEditViewController.swift
@IBAction func unwindToRecipeDetail(_ segue: UIStoryboardSegue) {
// Handle save/cancel result from edit flow
}Benefits:
- The entire navigation flow is visible in Interface Builder's storyboard canvas
- Forward and backward transitions use matched animation contexts
- New team members can understand the full flow without reading every view controller
Use Container Views for Embedded Child View Controllers
A single view controller that manages a complex screen with multiple independent sections (e.g., a header, a map, and a feed) accumulates hundreds of lines of unrelated logic. Dragging Container Views from the Object Library in Interface Builder creates embed segues that instantiate child view controllers automatically, splitting responsibilities along clear boundaries.
Incorrect (one massive view controller managing all sections):
// PropertyDetailViewController.swift — 600+ lines managing everything
class PropertyDetailViewController: UIViewController,
MKMapViewDelegate, UICollectionViewDataSource, UICollectionViewDelegate {
@IBOutlet weak var headerImageView: UIImageView!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var priceLabel: UILabel!
@IBOutlet weak var mapView: MKMapView!
@IBOutlet weak var photosCollectionView: UICollectionView!
@IBOutlet weak var descriptionTextView: UITextView!
@IBOutlet weak var amenitiesStackView: UIStackView!
@IBOutlet weak var agentNameLabel: UILabel!
@IBOutlet weak var agentPhoneLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
configureHeader()
configureMap()
configurePhotosCarousel()
configureDescription()
configureAmenities()
configureAgentContact()
}
// ... 400+ more lines of mixed concerns
}Correct (split into child view controllers via container views in IB):
// PropertyDetailViewController.swift — orchestrator only
class PropertyDetailViewController: UIViewController {
var property: Property!
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
switch segue.identifier {
case "EmbedMap":
let mapVC = segue.destination as! PropertyMapViewController
mapVC.coordinate = property.coordinate
case "EmbedPhotos":
let photosVC = segue.destination as! PropertyPhotosViewController
photosVC.photoURLs = property.photoURLs
case "EmbedAgentContact":
let agentVC = segue.destination as! AgentContactViewController
agentVC.agent = property.agent
default:
break
}
}
}<!-- PropertyDetail.storyboard — container views create embed segues automatically -->
<scene sceneID="propertyDetail">
<viewController id="PropertyDetailVC" sceneMemberID="viewController">
<view key="view">
<subviews>
<containerView id="mapContainer" translatesAutoresizingMaskIntoConstraints="NO">
<connections>
<segue destination="PropertyMapVC" kind="embed"
identifier="EmbedMap" id="seg-embed-map"/>
</connections>
</containerView>
<containerView id="photosContainer" translatesAutoresizingMaskIntoConstraints="NO">
<connections>
<segue destination="PropertyPhotosVC" kind="embed"
identifier="EmbedPhotos" id="seg-embed-photos"/>
</connections>
</containerView>
<containerView id="agentContainer" translatesAutoresizingMaskIntoConstraints="NO">
<connections>
<segue destination="AgentContactVC" kind="embed"
identifier="EmbedAgentContact" id="seg-embed-agent"/>
</connections>
</containerView>
</subviews>
</view>
</viewController>
</scene>Benefits:
- Each child view controller is independently testable and reusable across screens
- The parent controller shrinks to a thin orchestrator that passes data via embed segues
- Adding or removing a section is a single drag operation in Interface Builder
Validate Segue Conditions with shouldPerformSegue
When a segue is wired directly to a button in Interface Builder, tapping the button fires the segue unconditionally. If the screen requires valid input before proceeding, the user lands on the next screen with incomplete or invalid data. Overriding shouldPerformSegue(withIdentifier:sender:) intercepts the transition and keeps the user on the current screen until all conditions are met.
Incorrect (segue fires without validating required fields):
// ShippingAddressViewController.swift
// Segue wired from "Continue" button directly to PaymentViewController in IB
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "ProceedToPayment",
let paymentVC = segue.destination as? PaymentViewController {
// addressLine1 might be empty — PaymentVC receives invalid data
paymentVC.shippingAddress = ShippingAddress(
line1: addressLine1Field.text ?? "",
city: cityField.text ?? "",
postalCode: postalCodeField.text ?? ""
)
}
}Correct (validate before allowing the segue to proceed):
// ShippingAddressViewController.swift
override func shouldPerformSegue(
withIdentifier identifier: String,
sender: Any?
) -> Bool {
guard identifier == "ProceedToPayment" else { return true }
let isValid = [addressLine1Field, cityField, postalCodeField]
.allSatisfy { ($0.text ?? "").isEmpty == false }
if !isValid {
showValidationError("Please fill in all address fields.")
}
return isValid
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "ProceedToPayment",
let paymentVC = segue.destination as? PaymentViewController {
paymentVC.shippingAddress = ShippingAddress(
line1: addressLine1Field.text!,
city: cityField.text!,
postalCode: postalCodeField.text!
)
}
}Benefits:
- Validation logic stays in the source view controller instead of leaking into the destination
- The segue connection in Interface Builder remains intact, keeping the flow visible
- Works with any trigger (button, cell selection, gesture) without additional guard code
Pass Data via prepare(for:sender:) Instead of Direct Property Access
Instantiating and configuring destination view controllers directly creates a hard dependency between the source and destination, making it impossible to reuse either controller independently. Using prepare(for:sender:) keeps data passing centralized in the segue lifecycle where Interface Builder can validate connections at build time.
Incorrect (directly instantiating and setting properties bypasses the segue lifecycle):
// OrderListViewController.swift
func showOrderDetail(for order: Order) {
let storyboard = UIStoryboard(name: "Orders", bundle: nil)
// Tightly couples this VC to the destination's concrete type and storyboard ID
let detailVC = storyboard.instantiateViewController(
withIdentifier: "OrderDetailViewController"
) as! OrderDetailViewController
detailVC.order = order
detailVC.delegate = self
navigationController?.pushViewController(detailVC, animated: true)
}Correct (use prepare(for:sender:) to pass data through the segue):
// OrderListViewController.swift
func showOrderDetail(for order: Order) {
selectedOrder = order
performSegue(withIdentifier: "ShowOrderDetail", sender: self)
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "ShowOrderDetail",
let detailVC = segue.destination as? OrderDetailViewController {
detailVC.order = selectedOrder
detailVC.delegate = self
}
}Benefits:
- Navigation flow is visible in Interface Builder, making it auditable without reading code
- Destination view controller can be swapped in the storyboard without changing source code
- Segue identifier mismatches are caught early with runtime assertions
Use Unwind Segues to Navigate Backward
Manually calling dismiss(animated:) or popViewController(animated:) bypasses the storyboard's navigation graph and can leave orphaned view controllers in memory. Unwind segues let UIKit properly tear down the navigation stack back to the target controller, releasing intermediate controllers and triggering the correct lifecycle callbacks.
Incorrect (manual dismiss creates fragile navigation and potential memory leaks):
// CheckoutConfirmationViewController.swift
@IBAction func returnToProductList(_ sender: UIButton) {
// Pops only one level — breaks if an extra VC was pushed between
navigationController?.popToRootViewController(animated: true)
}
@IBAction func dismissFlow(_ sender: UIButton) {
// Dismisses only the presented VC, not the entire modal navigation stack
dismiss(animated: true, completion: nil)
}Correct (define the unwind action on the DESTINATION controller, wire it in IB):
// ProductListViewController.swift — the controller you're unwinding TO
@IBAction func unwindToProductList(_ segue: UIStoryboardSegue) {
if let confirmationVC = segue.source as? CheckoutConfirmationViewController {
completedOrderId = confirmationVC.orderId
}
}In Interface Builder, Control-drag from the CheckoutConfirmationViewController's trigger button to the Exit icon at the top of the scene, then select unwindToProductList:.
When NOT to use:
- Programmatic dismiss is acceptable for self-contained modal flows presented outside of storyboards (e.g.,
UIImagePickerController)
Reference:
Configure .gitattributes to Use Union Merge for Storyboards
By default, git uses a text-based three-way merge for storyboard files, which produces conflict markers inside XML that Xcode cannot parse. Configuring a merge strategy in .gitattributes either prevents automatic merging entirely (binary strategy) or attempts a union merge that accepts both sides. Either approach is safer than the default, which silently corrupts storyboard XML when conflict markers are left in place.
Incorrect (default merge strategy produces unparseable XML):
# .gitattributes — no storyboard-specific configuration
# Git treats .storyboard as text and inserts <<<<<<< markers
# Result after a conflicted merge:
<<<<<<< HEAD
<constraint firstItem="title" firstAttribute="top"
secondItem="safe-area" secondAttribute="top" constant="16"/>
=======
<constraint firstItem="title" firstAttribute="top"
secondItem="safe-area" secondAttribute="top" constant="24"/>
>>>>>>> feature/redesign
# Xcode fails to open the storyboard — XML parse errorCorrect (binary merge strategy forces manual resolution):
# .gitattributes
*.storyboard binary -merge
*.xib binary -mergeThis tells git to never attempt automatic merging. Any conflict requires the developer to choose one version and re-apply their changes manually, which is safer than corrupted XML.
Alternative (union merge accepts both sides):
# .gitattributes
*.storyboard merge=union
*.xib merge=unionUnion merge includes lines from both sides without conflict markers. This works when edits are in different scenes but can produce duplicate elements when both sides modify the same scene. Use with caution and always validate the storyboard opens in Xcode after merging.
Benefits:
- Binary strategy: guarantees no silent XML corruption from conflict markers
- Union strategy: resolves non-overlapping scene edits automatically
- Both strategies prevent Xcode from encountering unparseable storyboard files
Use Git File Locking for Active Storyboard Edits
Even with well-split storyboards, two developers occasionally need to edit the same file (e.g., during a design system migration). Git LFS file locking provides an advisory lock that signals to the team that a storyboard is being actively edited, preventing a second developer from starting work that will inevitably conflict.
Incorrect (no locking, two developers unknowingly edit the same storyboard):
# Developer A starts working on Checkout.storyboard
# No signal to the team — Developer B also opens Checkout.storyboard
# Hours later, Developer B pushes first
git add Checkout.storyboard
git commit -m "Update payment form layout"
git push
# Developer A tries to push and gets a merge conflict in XML
git push
# error: failed to push — diverged history on Checkout.storyboard
# Manual XML merge required — high risk of corruptionCorrect (git lfs lock prevents parallel edits):
First, configure LFS tracking for storyboard files:
# One-time setup
git lfs install
git lfs track "*.storyboard"
git add .gitattributes
git commit -m "Track storyboards with Git LFS"Then lock before editing and unlock after committing:
# Developer A locks the file before starting work
git lfs lock Checkout.storyboard
# Lock acquired: Checkout.storyboard (by sarah)
# Developer B attempts to edit
git lfs lock Checkout.storyboard
# Lock failed: already locked by sarah
# Developer A finishes, commits, and unlocks
git add Checkout.storyboard
git commit -m "Update payment form layout"
git lfs unlock Checkout.storyboardList all current locks to see who is editing which storyboard:
git lfs locks
# Checkout.storyboard sarah ID:123When NOT to use:
Teams that have already split storyboards to one-scene-per-file granularity rarely need locking. The overhead of lock/unlock is only justified when storyboard files are shared across developers.
Reference: Git LFS File Locking
Assign Storyboard Scenes to Individual Developers
When two developers edit scenes in the same storyboard file simultaneously, git produces merge conflicts in XML that are nearly impossible to resolve by hand. Xcode rewrites element ordering, regenerates object IDs, and updates connection metadata on every save, so even non-overlapping scene edits create hundreds of conflicting lines. Assigning storyboard ownership per developer or splitting scenes into separate storyboard files eliminates this entirely.
Incorrect (two developers editing the same storyboard, producing unresolvable conflicts):
<<<<<<< HEAD
<scene sceneID="checkout-1">
<objects>
<viewController id="CheckoutVC" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="abc-123">
<rect key="frame" x="0" y="0" width="375" height="812"/>
=======
<scene sceneID="checkout-1">
<objects>
<viewController id="CheckoutVC" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="abc-123">
<rect key="frame" x="0" y="0" width="393" height="852"/>
>>>>>>> feature/payment-redesignCorrect (split storyboards with CODEOWNERS enforcing ownership):
# .github/CODEOWNERS
# Each storyboard owned by one developer or team
Checkout.storyboard @sarah
Profile.storyboard @marcus
Onboarding.storyboard @team-growth
Search.storyboard @alexPair this with feature-scoped storyboards containing 3-5 scenes each:
Storyboards/
Checkout.storyboard # Sarah's feature
Profile.storyboard # Marcus's feature
Onboarding.storyboard # Growth team
Search.storyboard # Alex's featureBenefits:
- CODEOWNERS blocks PRs that modify another developer's storyboard without review
- Small storyboards produce readable XML diffs in pull requests
- Developers never need to coordinate who has a storyboard "checked out"
Review Storyboard Diffs as Source Code Before Committing
Xcode's Interface Builder rewrites storyboard XML on every save, even when the developer made no intentional changes. Opening a storyboard, scrolling the canvas, or selecting an element can reorder XML attributes, update translatesAutoresizingMaskIntoConstraints, change rect frame values, or bump toolsVersion. Committing these noise changes buries real modifications and makes future git blame useless.
Incorrect (blindly committing all IB-generated changes):
<!-- git diff shows 47 changed lines, but developer only added one label -->
- <rect key="frame" x="16" y="200" width="343" height="44"/>
+ <rect key="frame" x="16" y="200" width="343.00000000000006" height="44"/>
- <constraint firstItem="title-label" firstAttribute="top"
- secondItem="safe-area" secondAttribute="top" constant="200"/>
+ <constraint firstItem="title-label" firstAttribute="top"
+ secondItem="safe-area" secondAttribute="top" constant="200.00000000000003"/>
<!-- Unrelated toolsVersion bump -->
- <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB"
- version="3.0" toolsVersion="21701">
+ <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB"
+ version="3.0" toolsVersion="22505">Correct (review XML source, stage only intentional changes):
Right-click the storyboard in Xcode's Project Navigator and select "Open As > Source Code" to inspect the raw XML before committing. Then use git add -p to stage only the meaningful hunks:
# Review the full diff first
git diff Checkout.storyboard
# Interactively stage only intentional changes
git add -p Checkout.storyboardThe intentional change in isolation:
<!-- Only the new label and its constraints are staged -->
<label id="discount-label" text="20% off"
textAlignment="center" lineBreakMode="tailTruncation">
<rect key="frame" x="16" y="252" width="343" height="21"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubheadline"/>
</label>
<constraint firstItem="discount-label" firstAttribute="top"
secondItem="price-label" secondAttribute="bottom" constant="8"/>Benefits:
- git blame remains useful because each line traces to a deliberate change
- Pull request reviewers see only real modifications, not IB noise
- Floating-point rect drift is caught before it enters the repository
Avoid Deeply Nested Stack Views Beyond Two Levels
Each level of stack view nesting adds a round of recursive systemLayoutSizeFitting calls during layout. Beyond two levels, the layout engine performs exponentially more constraint-solving passes, which causes visible frame drops when the hierarchy is embedded in a UIScrollView or UITableViewCell that recalculates layout during scrolling.
Incorrect (4-level deep stack view nesting):
<!-- Deeply nested stacks cause O(n^2) layout passes in scroll views -->
<stackView id="outer-stack" axis="vertical" spacing="16">
<subviews>
<stackView id="section-stack" axis="vertical" spacing="12">
<subviews>
<stackView id="row-stack" axis="horizontal" spacing="8">
<subviews>
<stackView id="icon-label-stack" axis="horizontal" spacing="4">
<subviews>
<imageView id="rating-icon" image="star.fill"/>
<label id="rating-label" text="4.8"/>
</subviews>
</stackView>
<label id="review-count" text="(2,341 reviews)"/>
</subviews>
</stackView>
<label id="description-label" text="Award-winning restaurant..."/>
</subviews>
</stackView>
<label id="address-label" text="123 Main Street"/>
</subviews>
</stackView>Correct (flattened to maximum 2 levels of nesting):
<stackView id="outer-stack" axis="vertical" spacing="16">
<subviews>
<stackView id="rating-row" axis="horizontal" spacing="4">
<subviews>
<imageView id="rating-icon" image="star.fill"/>
<label id="rating-label" text="4.8"/>
<label id="review-count" text="(2,341 reviews)"/>
</subviews>
</stackView>
<label id="description-label" text="Award-winning restaurant..."/>
<label id="address-label" text="123 Main Street"/>
</subviews>
</stackView>When NOT to use this rule:
- Static layouts that are never embedded in scroll views or table cells can tolerate deeper nesting without visible performance impact. Measure with Instruments Time Profiler before flattening stable layouts.
Reference: Optimizing Auto Layout
Enable Clip to Bounds for Views with Corner Radius
Setting layer.cornerRadius in a User Defined Runtime Attribute or via @IBInspectable rounds the background but does not clip subviews or the content of UIImageView. Without clipsToBounds, images and child views overflow the rounded corners, producing rectangular artifacts that break the intended design.
Incorrect (cornerRadius without clipsToBounds):
<view id="avatar-container" customClass="AvatarView">
<rect key="frame" x="16" y="100" width="80" height="80"/>
<subviews>
<imageView id="avatar-image" image="profile-photo"
contentMode="scaleAspectFill">
<rect key="frame" x="0" y="0" width="80" height="80"/>
</imageView>
</subviews>
<userDefinedRuntimeAttributes>
<!-- Image overflows the rounded corners -->
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<real key="value" value="40"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</view>Correct (clipsToBounds clips content to the rounded path):
<view id="avatar-container" customClass="AvatarView" clipsToBounds="YES">
<rect key="frame" x="16" y="100" width="80" height="80"/>
<subviews>
<imageView id="avatar-image" image="profile-photo"
contentMode="scaleAspectFill">
<rect key="frame" x="0" y="0" width="80" height="80"/>
</imageView>
</subviews>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<real key="value" value="40"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</view>When NOT to use:
Avoid clipsToBounds on views that intentionally render shadows via layer.shadowPath, since clipping removes the shadow. In that case, use a separate container view for the shadow and an inner view with clipping for the corner radius.
Set Correct Content Mode for UIImageView in Storyboard
The default content mode for UIImageView is scaleToFill, which stretches the image to match the view's frame regardless of aspect ratio. For photographs, icons, and user-uploaded content this produces visually distorted results. Choosing scaleAspectFit or scaleAspectFill preserves the original aspect ratio.
Incorrect (default scaleToFill distorts non-square images):
<imageView id="product-image" image="product-hero"
contentMode="scaleToFill">
<!-- 16:9 image stretched into a square frame -->
<rect key="frame" x="0" y="0" width="375" height="375"/>
</imageView>Correct (scaleAspectFill preserves aspect ratio with clipping):
<imageView id="product-image" image="product-hero"
contentMode="scaleAspectFill" clipsToBounds="YES">
<rect key="frame" x="0" y="0" width="375" height="375"/>
</imageView>Alternative:
Use scaleAspectFit when the entire image must be visible without cropping (e.g., a document scan or diagram). The view will show letterbox/pillarbox bars if the aspect ratios do not match:
<imageView id="document-scan" image="receipt-photo"
contentMode="scaleAspectFit">
<rect key="frame" x="16" y="100" width="343" height="480"/>
</imageView>| Content Mode | Behavior | Use Case |
|---|---|---|
scaleAspectFill | Fills frame, clips overflow | Photos, avatars, hero images |
scaleAspectFit | Fits inside frame, may letterbox | Documents, diagrams, logos |
scaleToFill | Stretches to fill exactly | Gradient backgrounds, solid fills |
Reference: UIView.ContentMode
Rely on Intrinsic Content Size for Standard UIKit Controls
UILabel, UIButton, UIImageView, UISwitch, and UITextField all report an intrinsic content size based on their text, image, or fixed dimensions. Adding explicit width and height constraints on these controls duplicates what the layout engine already knows, creates maintenance overhead when content changes, and prevents Dynamic Type from resizing text naturally.
Incorrect (explicit width and height on a UIButton):
<button id="checkout-btn" buttonType="system">
<rect key="frame" x="16" y="400" width="343" height="50"/>
<state key="normal" title="Proceed to Checkout"/>
<constraints>
<!-- Redundant: UIButton already sizes itself from its title -->
<constraint firstAttribute="width" constant="343"/>
<constraint firstAttribute="height" constant="50"/>
</constraints>
</button>Correct (position-only constraints, intrinsic size handles dimensions):
<button id="checkout-btn" buttonType="system">
<rect key="frame" x="16" y="400" width="343" height="50"/>
<state key="normal" title="Proceed to Checkout"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<constraints>
<constraint firstAttribute="height" constant="50" identifier="min-tap-target"/>
</constraints>
</button>Alternative:
When a minimum tap target height is required (44pt per Apple HIG), set a >= height constraint instead of an exact value. This allows Dynamic Type to grow the button beyond 44pt while enforcing a floor:
<constraint firstAttribute="height" relation="greaterThanOrEqual"
constant="44" identifier="min-tap-target"/>Controls with intrinsic content size: UILabel, UIButton, UIImageView (when image is set), UISwitch, UITextField, UISegmentedControl, UISlider, UIActivityIndicatorView.
Reference: Intrinsic Content Size
Use Placeholder Intrinsic Size for Custom Views in Storyboard
Custom views that compute their size at runtime (e.g., a chart view or a dynamically loaded image container) trigger Interface Builder constraint warnings because IB cannot determine their dimensions at design time. Developers often add fixed-width and fixed-height constraints to silence the warnings, but these constraints override the runtime size and cause incorrect layouts. Setting the Intrinsic Size to "Placeholder" in the Size Inspector tells IB to assume a size for validation only, without generating any runtime constraints.
Incorrect (fixed constraints added to silence IB warnings):
<view contentMode="scaleToFill" id="chart-container"
customClass="SalesChartView" customModule="Analytics">
<rect key="frame" x="16" y="200" width="343" height="220"/>
<constraints>
<!-- Added only to silence IB; overrides runtime layout -->
<constraint firstAttribute="width" constant="343"/>
<constraint firstAttribute="height" constant="220"/>
</constraints>
</view>Correct (placeholder intrinsic size removes IB warnings without runtime side effects):
<view contentMode="scaleToFill" id="chart-container"
customClass="SalesChartView" customModule="Analytics">
<rect key="frame" x="16" y="200" width="343" height="220"/>
<!-- Placeholder only: IB uses this for canvas rendering, not at runtime -->
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="size" keyPath="intrinsicContentSize">
<size key="value" width="343" height="220"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</view>To set this in Xcode: select the custom view, open the Size Inspector, and change the Intrinsic Size dropdown from "Default (System Defined)" to "Placeholder". Enter the design-time width and height. These values are stripped at build time and have no effect on the running app.
Reference: Interface Builder Help: Setting Placeholder Constraints
Related skills
FAQ
What does ios-storyboard do?
ios-storyboard: A skill for development. This provides functionality for development workflows.
When should I use ios-storyboard?
When you need to use ios-storyboard for development tasks, or when ios-storyboard: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
ios-storyboard.