Copilot, Claude, Cursor, and others all read from ~/.agents/. The npx skills CLI handles fan-out to tool-specific directories.
360 lines
8.2 KiB
Markdown
360 lines
8.2 KiB
Markdown
---
|
|
name: SwiftUI Accessibility
|
|
description: Dynamic Type support and VoiceOver accessibility implementation
|
|
globs: ["**/*.swift"]
|
|
---
|
|
|
|
# Accessibility: Dynamic Type and VoiceOver
|
|
|
|
Accessibility is not optional. All apps must support Dynamic Type and VoiceOver.
|
|
|
|
## Dynamic Type
|
|
|
|
### Always Support Dynamic Type
|
|
|
|
Use system text styles that scale automatically:
|
|
|
|
```swift
|
|
// GOOD - Scales with Dynamic Type
|
|
Text("Title")
|
|
.font(.title)
|
|
|
|
Text("Body text")
|
|
.font(.body)
|
|
|
|
Text("Caption")
|
|
.font(.caption)
|
|
```
|
|
|
|
### Use @ScaledMetric for Custom Dimensions
|
|
|
|
When you need custom sizes that should scale with Dynamic Type:
|
|
|
|
```swift
|
|
struct CustomCard: View {
|
|
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 24
|
|
@ScaledMetric(relativeTo: .body) private var spacing: CGFloat = 12
|
|
@ScaledMetric(relativeTo: .title) private var headerHeight: CGFloat = 44
|
|
|
|
var body: some View {
|
|
VStack(spacing: spacing) {
|
|
Image(systemName: "star")
|
|
.font(.system(size: iconSize))
|
|
Text("Content")
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Choose Appropriate relativeTo Styles
|
|
|
|
Match the scaling behavior to the content's purpose:
|
|
|
|
| Content Type | relativeTo |
|
|
|-------------|-----------|
|
|
| Body content spacing | `.body` |
|
|
| Title decorations | `.title` |
|
|
| Caption elements | `.caption` |
|
|
| Large headers | `.largeTitle` |
|
|
|
|
### Fixed Sizes (Use Sparingly)
|
|
|
|
Only use fixed sizes when absolutely necessary, and document the reason:
|
|
|
|
```swift
|
|
// Fixed size for app icon badge - must match system badge size
|
|
private let badgeSize: CGFloat = 24 // Fixed: matches system notification badge
|
|
|
|
// Fixed for external API requirements
|
|
private let avatarUploadSize: CGFloat = 256 // Fixed: server requires exactly 256x256
|
|
```
|
|
|
|
### Prefer System Text Styles
|
|
|
|
```swift
|
|
// GOOD - System styles
|
|
.font(.body)
|
|
.font(.headline)
|
|
.font(.title)
|
|
.font(.caption)
|
|
|
|
// AVOID - Custom sizes that don't scale
|
|
.font(.system(size: 14))
|
|
|
|
// IF you must use custom sizes, use ScaledMetric
|
|
@ScaledMetric private var customSize: CGFloat = 14
|
|
.font(.system(size: customSize))
|
|
```
|
|
|
|
## VoiceOver
|
|
|
|
### Accessibility Labels
|
|
|
|
All interactive elements must have meaningful labels:
|
|
|
|
```swift
|
|
// GOOD - Descriptive label
|
|
Button { } label: {
|
|
Image(systemName: "trash")
|
|
}
|
|
.accessibilityLabel("Delete item")
|
|
|
|
// GOOD - Context-aware label
|
|
Button { } label: {
|
|
Image(systemName: "heart.fill")
|
|
}
|
|
.accessibilityLabel(item.isFavorite ? "Remove from favorites" : "Add to favorites")
|
|
```
|
|
|
|
### Accessibility Values
|
|
|
|
Use for dynamic state that changes:
|
|
|
|
```swift
|
|
Slider(value: $volume)
|
|
.accessibilityLabel("Volume")
|
|
.accessibilityValue("\(Int(volume * 100)) percent")
|
|
|
|
Toggle(isOn: $isEnabled) {
|
|
Text("Notifications")
|
|
}
|
|
.accessibilityValue(isEnabled ? "On" : "Off")
|
|
```
|
|
|
|
### Accessibility Hints
|
|
|
|
Describe what happens when the user interacts:
|
|
|
|
```swift
|
|
Button("Submit") { }
|
|
.accessibilityLabel("Submit order")
|
|
.accessibilityHint("Double-tap to place your order and proceed to payment")
|
|
|
|
NavigationLink(value: item) {
|
|
ItemRow(item: item)
|
|
}
|
|
.accessibilityHint("Opens item details")
|
|
```
|
|
|
|
### Accessibility Traits
|
|
|
|
Use traits to convey element type and behavior:
|
|
|
|
```swift
|
|
// Button trait (usually automatic)
|
|
Text("Tap me")
|
|
.onTapGesture { }
|
|
.accessibilityAddTraits(.isButton)
|
|
|
|
// Header trait for section headers
|
|
Text("Settings")
|
|
.font(.headline)
|
|
.accessibilityAddTraits(.isHeader)
|
|
|
|
// Selected state
|
|
ItemRow(item: item)
|
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
|
|
// Image trait removal for decorative images
|
|
Image("decorative-background")
|
|
.accessibilityHidden(true)
|
|
```
|
|
|
|
### Hide Decorative Elements
|
|
|
|
Hide elements that don't provide meaningful information:
|
|
|
|
```swift
|
|
// Decorative separator
|
|
Divider()
|
|
.accessibilityHidden(true)
|
|
|
|
// Background decoration
|
|
Image("pattern")
|
|
.accessibilityHidden(true)
|
|
|
|
// Redundant icon next to text
|
|
HStack {
|
|
Image(systemName: "envelope")
|
|
.accessibilityHidden(true) // Label conveys the meaning
|
|
Text("Email")
|
|
}
|
|
```
|
|
|
|
### Group Related Elements
|
|
|
|
Reduce navigation complexity by grouping related content:
|
|
|
|
```swift
|
|
// GOOD - Single VoiceOver element
|
|
HStack {
|
|
Image(systemName: "person")
|
|
VStack(alignment: .leading) {
|
|
Text(user.name)
|
|
Text(user.email)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
|
|
// OR create a completely custom accessibility representation
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("\(user.name), \(user.email)")
|
|
```
|
|
|
|
### Accessibility Actions
|
|
|
|
Add custom actions for complex interactions:
|
|
|
|
```swift
|
|
ItemRow(item: item)
|
|
.accessibilityAction(named: "Delete") {
|
|
deleteItem(item)
|
|
}
|
|
.accessibilityAction(named: "Edit") {
|
|
editItem(item)
|
|
}
|
|
.accessibilityAction(named: "Share") {
|
|
shareItem(item)
|
|
}
|
|
```
|
|
|
|
### Accessibility Announcements
|
|
|
|
Announce important state changes:
|
|
|
|
```swift
|
|
func completeTask() {
|
|
task.isCompleted = true
|
|
|
|
// Announce the change
|
|
AccessibilityNotification.Announcement("Task completed")
|
|
.post()
|
|
}
|
|
|
|
func showError(_ message: String) {
|
|
errorMessage = message
|
|
|
|
// Announce errors immediately
|
|
AccessibilityNotification.Announcement(message)
|
|
.post()
|
|
}
|
|
```
|
|
|
|
### Accessibility Focus
|
|
|
|
Control focus for important UI changes:
|
|
|
|
```swift
|
|
struct ContentView: View {
|
|
@AccessibilityFocusState private var isSearchFocused: Bool
|
|
@State private var showingSearch = false
|
|
|
|
var body: some View {
|
|
VStack {
|
|
if showingSearch {
|
|
TextField("Search", text: $searchText)
|
|
.accessibilityFocused($isSearchFocused)
|
|
}
|
|
|
|
Button("Search") {
|
|
showingSearch = true
|
|
isSearchFocused = true // Move focus to search field
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Common Patterns
|
|
|
|
### Cards and List Items
|
|
|
|
```swift
|
|
struct ItemCard: View {
|
|
let item: Item
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading) {
|
|
Text(item.title)
|
|
.font(.headline)
|
|
Text(item.subtitle)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack {
|
|
Label("\(item.likes)", systemImage: "heart")
|
|
Label("\(item.comments)", systemImage: "bubble.right")
|
|
}
|
|
.font(.caption)
|
|
}
|
|
// Combine into single VoiceOver element
|
|
.accessibilityElement(children: .combine)
|
|
// Add meaningful summary
|
|
.accessibilityLabel("\(item.title), \(item.subtitle)")
|
|
.accessibilityValue("\(item.likes) likes, \(item.comments) comments")
|
|
}
|
|
}
|
|
```
|
|
|
|
### Interactive Charts
|
|
|
|
```swift
|
|
Chart {
|
|
ForEach(data) { point in
|
|
LineMark(x: .value("Date", point.date), y: .value("Value", point.value))
|
|
}
|
|
}
|
|
.accessibilityLabel("Sales chart")
|
|
.accessibilityValue("Showing data from \(startDate) to \(endDate)")
|
|
.accessibilityHint("Swipe up or down to hear individual data points")
|
|
.accessibilityChartDescriptor(self)
|
|
```
|
|
|
|
### Custom Controls
|
|
|
|
```swift
|
|
struct RatingControl: View {
|
|
@Binding var rating: Int
|
|
|
|
var body: some View {
|
|
HStack {
|
|
ForEach(1...5, id: \.self) { star in
|
|
Image(systemName: star <= rating ? "star.fill" : "star")
|
|
.onTapGesture { rating = star }
|
|
}
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("Rating")
|
|
.accessibilityValue("\(rating) of 5 stars")
|
|
.accessibilityAdjustableAction { direction in
|
|
switch direction {
|
|
case .increment:
|
|
rating = min(5, rating + 1)
|
|
case .decrement:
|
|
rating = max(1, rating - 1)
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing Accessibility
|
|
|
|
### Enable VoiceOver in Simulator
|
|
|
|
1. Settings → Accessibility → VoiceOver
|
|
2. Or use Accessibility Inspector (Xcode → Open Developer Tool)
|
|
|
|
### Audit Checklist
|
|
|
|
- [ ] All interactive elements have labels
|
|
- [ ] Dynamic content announces changes
|
|
- [ ] Decorative elements are hidden
|
|
- [ ] Text scales with Dynamic Type
|
|
- [ ] Touch targets are at least 44pt
|
|
- [ ] Color is not the only indicator of state
|
|
- [ ] Groups reduce navigation complexity
|