Files
2025-11-29 18:28:37 +08:00

450 lines
9.7 KiB
Markdown

# Accessibility
VoiceOver, Dynamic Type, and inclusive design for iOS apps.
## VoiceOver Support
### Basic Labels
```swift
struct ItemRow: View {
let item: Item
var body: some View {
HStack {
Image(systemName: item.icon)
.accessibilityHidden(true) // Icon is decorative
VStack(alignment: .leading) {
Text(item.name)
Text(item.date, style: .date)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if item.isCompleted {
Image(systemName: "checkmark")
.accessibilityHidden(true)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(item.name), \(item.isCompleted ? "completed" : "incomplete")")
.accessibilityHint("Double tap to view details")
}
}
```
### Custom Actions
```swift
struct ItemRow: View {
let item: Item
let onDelete: () -> Void
let onToggle: () -> Void
var body: some View {
HStack {
Text(item.name)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(item.name)
.accessibilityAction(named: "Toggle completion") {
onToggle()
}
.accessibilityAction(named: "Delete") {
onDelete()
}
}
}
```
### Traits
```swift
Text("Important Notice")
.accessibilityAddTraits(.isHeader)
Button("Submit") { }
.accessibilityAddTraits(.startsMediaSession)
Image("photo")
.accessibilityAddTraits(.isImage)
Link("Learn more", destination: url)
.accessibilityAddTraits(.isLink)
Toggle("Enable", isOn: $isEnabled)
.accessibilityAddTraits(isEnabled ? .isSelected : [])
```
### Announcements
```swift
// Announce changes
func saveCompleted() {
AccessibilityNotification.Announcement("Item saved successfully").post()
}
// Screen change
func showNewScreen() {
AccessibilityNotification.ScreenChanged(nil).post()
}
// Layout change
func expandSection() {
isExpanded = true
AccessibilityNotification.LayoutChanged(nil).post()
}
```
### Rotor Actions
```swift
struct ArticleView: View {
@State private var fontSize: CGFloat = 16
var body: some View {
Text(article.content)
.font(.system(size: fontSize))
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
fontSize = min(fontSize + 2, 32)
case .decrement:
fontSize = max(fontSize - 2, 12)
@unknown default:
break
}
}
}
}
```
## Dynamic Type
### Scaled Fonts
```swift
// System fonts scale automatically
Text("Title")
.font(.title)
Text("Body")
.font(.body)
// Custom fonts with scaling
Text("Custom")
.font(.custom("Helvetica", size: 17, relativeTo: .body))
// Fixed size (use sparingly)
Text("Fixed")
.font(.system(size: 12).fixed())
```
### Scaled Metrics
```swift
struct IconButton: View {
@ScaledMetric var iconSize: CGFloat = 24
@ScaledMetric(relativeTo: .body) var spacing: CGFloat = 8
var body: some View {
HStack(spacing: spacing) {
Image(systemName: "star")
.font(.system(size: iconSize))
Text("Favorite")
}
}
}
```
### Line Limits with Accessibility
```swift
Text(item.description)
.lineLimit(3)
.truncationMode(.tail)
// But allow more for accessibility sizes
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
```
### Testing Dynamic Type
```swift
#Preview("Default") {
ContentView()
}
#Preview("Large") {
ContentView()
.environment(\.sizeCategory, .accessibilityLarge)
}
#Preview("Extra Extra Large") {
ContentView()
.environment(\.sizeCategory, .accessibilityExtraExtraLarge)
}
```
## Reduce Motion
```swift
struct AnimatedView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isExpanded = false
var body: some View {
VStack {
// Content
}
.animation(reduceMotion ? .none : .spring(), value: isExpanded)
}
}
// Alternative animations
struct TransitionView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var showDetail = false
var body: some View {
VStack {
if showDetail {
DetailView()
.transition(reduceMotion ? .opacity : .slide)
}
}
.animation(.default, value: showDetail)
}
}
```
## Color and Contrast
### Semantic Colors
```swift
// Use semantic colors that adapt
Text("Primary")
.foregroundStyle(.primary)
Text("Secondary")
.foregroundStyle(.secondary)
Text("Tertiary")
.foregroundStyle(.tertiary)
// Error state
Text("Error")
.foregroundStyle(.red) // Use semantic red, not custom
```
### Increase Contrast
```swift
struct ContrastAwareView: View {
@Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
@Environment(\.accessibilityIncreaseContrast) private var increaseContrast
var body: some View {
HStack {
Circle()
.fill(increaseContrast ? .primary : .secondary)
if differentiateWithoutColor {
// Add non-color indicator
Image(systemName: "checkmark")
}
}
}
}
```
### Color Blind Support
```swift
struct StatusIndicator: View {
let status: Status
@Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
var body: some View {
HStack {
Circle()
.fill(status.color)
.frame(width: 10, height: 10)
if differentiateWithoutColor {
Image(systemName: status.icon)
}
Text(status.label)
}
}
}
enum Status {
case success, warning, error
var color: Color {
switch self {
case .success: return .green
case .warning: return .orange
case .error: return .red
}
}
var icon: String {
switch self {
case .success: return "checkmark.circle"
case .warning: return "exclamationmark.triangle"
case .error: return "xmark.circle"
}
}
var label: String {
switch self {
case .success: return "Success"
case .warning: return "Warning"
case .error: return "Error"
}
}
}
```
## Focus Management
### Focus State
```swift
struct LoginView: View {
@State private var username = ""
@State private var password = ""
@FocusState private var focusedField: Field?
enum Field {
case username, password
}
var body: some View {
Form {
TextField("Username", text: $username)
.focused($focusedField, equals: .username)
.submitLabel(.next)
.onSubmit {
focusedField = .password
}
SecureField("Password", text: $password)
.focused($focusedField, equals: .password)
.submitLabel(.done)
.onSubmit {
login()
}
}
.onAppear {
focusedField = .username
}
}
}
```
### Accessibility Focus
```swift
struct AlertView: View {
@AccessibilityFocusState private var isAlertFocused: Bool
var body: some View {
VStack {
Text("Important Alert")
.accessibilityFocused($isAlertFocused)
}
.onAppear {
isAlertFocused = true
}
}
}
```
## Button Shapes
```swift
struct AccessibleButton: View {
@Environment(\.accessibilityShowButtonShapes) private var showButtonShapes
var body: some View {
Button("Action") { }
.padding()
.background(showButtonShapes ? Color.accentColor.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
```
## Smart Invert Colors
```swift
Image("photo")
.accessibilityIgnoresInvertColors() // Photos shouldn't invert
```
## Audit Checklist
### VoiceOver
- [ ] All interactive elements have labels
- [ ] Decorative elements are hidden
- [ ] Custom actions for swipe gestures
- [ ] Headings marked correctly
- [ ] Announcements for dynamic changes
### Dynamic Type
- [ ] All text uses dynamic fonts
- [ ] Layout adapts to large sizes
- [ ] No text truncation at accessibility sizes
- [ ] Touch targets remain accessible (44pt minimum)
### Color and Contrast
- [ ] 4.5:1 contrast ratio for text
- [ ] Information not conveyed by color alone
- [ ] Works with Increase Contrast
- [ ] Works with Smart Invert
### Motion
- [ ] Animations respect Reduce Motion
- [ ] No auto-playing animations
- [ ] Alternative interactions for gesture-only features
### General
- [ ] All functionality available via VoiceOver
- [ ] Logical focus order
- [ ] Error messages are accessible
- [ ] Time limits are adjustable
## Testing Tools
### Accessibility Inspector
1. Open Xcode > Open Developer Tool > Accessibility Inspector
2. Point at elements to inspect labels, traits, hints
3. Run audit for common issues
### VoiceOver Practice
1. Settings > Accessibility > VoiceOver
2. Use with your app
3. Navigate by swiping, double-tap to activate
### Voice Control
1. Settings > Accessibility > Voice Control
2. Test all interactions with voice commands
### Xcode Previews
```swift
#Preview {
ContentView()
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
.environment(\.accessibilityReduceMotion, true)
.environment(\.accessibilityDifferentiateWithoutColor, true)
}
```