A comprehensive guide to building accessible iOS and Android applications - covering native apps, hybrid frameworks, touch targets, gestures, screen readers, and platform-specific accessibility features
Mobile devices have become the primary way billions of people access digital content and services. For many users with disabilities, mobile devices with built-in accessibility features like screen readers, voice control, and magnification are more affordable and accessible than traditional assistive technologies that require expensive desktop software or hardware.
Key mobile accessibility considerations:
Both major mobile platforms provide robust accessibility features, but they differ in implementation:
| Feature | iOS | Android |
|---|---|---|
| Screen Reader | VoiceOver (built-in) | TalkBack (built-in) |
| Activation | Triple-click Home/Side button or Settings | Volume keys or Settings |
| Gestures | Swipe right/left to navigate, double-tap to activate | Swipe right/left to navigate, double-tap to activate |
| Voice Control | Voice Control (separate from Siri) | Voice Access |
| Magnification | Zoom (3-finger double-tap) | Magnification (triple-tap or button) |
| Display Options | Display Accommodations, Larger Text | Font size, Display size, Color correction |
| Switch Control | Switch Control | Switch Access |
Mobile apps must comply with:
All UI elements should be accessible to VoiceOver. UIKit controls are accessible by default, but custom views need explicit configuration.
// Swift - Making a custom view accessible
class CustomButton: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
setupAccessibility()
}
func setupAccessibility() {
// Make the view accessible
isAccessibilityElement = true
// Set accessibility label (what the element is)
accessibilityLabel = "Submit form"
// Set accessibility trait (what kind of element)
accessibilityTraits = .button
// Optional: Set accessibility hint (what it does)
accessibilityHint = "Double tap to submit the form"
// Optional: Set accessibility value (current state)
// accessibilityValue = "Not submitted"
}
}
Best practices for labels and hints:
// Good examples button.accessibilityLabel = "Play" button.accessibilityHint = "Starts playback" slider.accessibilityLabel = "Volume" slider.accessibilityValue = "\(Int(slider.value * 100))%" // Bad examples button.accessibilityLabel = "Play button" // "button" is redundant button.accessibilityHint = "Button" // Not helpful textField.accessibilityLabel = "Type here" // Should describe what to type
Traits tell VoiceOver what kind of element it is and how to interact with it.
// Common accessibility traits
.button // "Button" - double tap to activate
.link // "Link" - opens content
.searchField // "Search field" - brings up keyboard
.image // "Image" - non-interactive image
.staticText // Plain text
.header // Heading (navigable with rotor)
.selected // "Selected" - current state
.notEnabled // "Dimmed" - disabled state
.adjustable // Can be incremented/decremented with swipe up/down
// Combining traits
view.accessibilityTraits = [.button, .selected]
// Custom controls
view.accessibilityTraits = .adjustable
view.accessibilityValue = "Medium"
// Implementing adjustable
override func accessibilityIncrement() {
// User swiped up
increaseValue()
}
override func accessibilityDecrement() {
// User swiped down
decreaseValue()
}
Group related elements so VoiceOver treats them as a single item, reducing navigation complexity.
// Group a card with image, title, and description cardView.isAccessibilityElement = true cardView.accessibilityLabel = "\(title), \(description)" cardView.accessibilityTraits = .button // Hide child elements from VoiceOver imageView.isAccessibilityElement = false titleLabel.isAccessibilityElement = false descriptionLabel.isAccessibilityElement = false
SwiftUI provides declarative modifiers for accessibility:
// SwiftUI accessibility modifiers
Button(action: submit) {
Text("Submit")
}
.accessibilityLabel("Submit form")
.accessibilityHint("Double tap to submit")
.accessibilityAddTraits(.isButton)
// Grouping
HStack {
Image("star")
Text("4.5 stars")
Text("(120 reviews)")
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Rating: 4.5 stars, 120 reviews")
// Custom actions
Text("Article title")
.accessibilityActions {
Button("Share") { shareArticle() }
Button("Bookmark") { bookmarkArticle() }
}
// Value and adjustable
Slider(value: $volume, in: 0...100)
.accessibilityLabel("Volume")
.accessibilityValue("\(Int(volume))%")
// Hidden elements
Image(decorative: "background")
.accessibilityHidden(true)
How to test with VoiceOver:
Content descriptions are the Android equivalent of accessibility labels.
<!-- XML -->
<ImageButton
android:id="@+id/playButton"
android:src="@drawable/ic_play"
android:contentDescription="@string/play_button" />
<!-- Decorative images -->
<ImageView
android:src="@drawable/decorative"
android:importantForAccessibility="no" />
// Kotlin - Setting programmatically
button.contentDescription = "Play audio"
// Dynamic content description
button.contentDescription = if (isPlaying) {
"Pause audio"
} else {
"Play audio"
}
<!-- Using android:hint -->
<EditText
android:id="@+id/emailInput"
android:hint="Email address"
android:inputType="textEmailAddress" />
<!-- Using android:labelFor for separate labels -->
<TextView
android:id="@+id/passwordLabel"
android:text="Password"
android:labelFor="@id/passwordInput" />
<EditText
android:id="@+id/passwordInput"
android:inputType="textPassword" />
// Kotlin - Adding custom actions
ViewCompat.addAccessibilityAction(
view,
"Share"
) { view, arguments ->
shareContent()
true
}
ViewCompat.addAccessibilityAction(
view,
"Bookmark"
) { view, arguments ->
bookmarkContent()
true
}
// User can access these via TalkBack's Actions menu
<!-- Group related content -->
<LinearLayout
android:screenReaderFocusable="true"
android:contentDescription="Product: Phone, $599, 4.5 stars">
<ImageView
android:src="@drawable/phone"
android:importantForAccessibility="no" />
<TextView
android:text="Phone"
android:importantForAccessibility="no" />
<TextView
android:text="$599"
android:importantForAccessibility="no" />
</LinearLayout>
<!-- Control focus order -->
<View
android:id="@+id/firstElement"
android:nextFocusForward="@+id/secondElement" />
// Compose accessibility modifiers
Button(
onClick = { submit() },
modifier = Modifier.semantics {
contentDescription = "Submit form"
role = Role.Button
}
) {
Text("Submit")
}
// Merge descendant semantics
Row(
modifier = Modifier.semantics(mergeDescendants = true) {
contentDescription = "4.5 stars, 120 reviews"
}
) {
Icon(Icons.Filled.Star, contentDescription = null)
Text("4.5")
Text("(120 reviews)")
}
// Custom actions
Text(
"Article title",
modifier = Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Share") {
shareArticle()
true
},
CustomAccessibilityAction("Bookmark") {
bookmarkArticle()
true
}
)
}
)
// State descriptions
Checkbox(
checked = isChecked,
onCheckedChange = { isChecked = it },
modifier = Modifier.semantics {
stateDescription = if (isChecked) "Checked" else "Unchecked"
}
)
How to test with TalkBack:
WCAG 2.1 Success Criterion 2.5.5 (Level AAA) requires touch targets to be at least 44×44 CSS pixels. Both iOS and Android recommend minimum 44×44 points (iOS) / 48×48 dp (Android).
/* iOS - Minimum touch target */
button.frame(minWidth: 44, minHeight: 44)
// Android - Minimum touch target
<Button
android:minWidth="48dp"
android:minHeight="48dp" />
// If visual size must be smaller, increase touch area
view.frame(width: 24, height: 24)
.contentShape(Rectangle())
.padding(10) // Creates 44×44 touch area
Touch target best practices:
WCAG 2.1 requires alternatives to path-based and multi-point gestures (2.5.1).
// GOOD: Pinch to zoom with buttons
ZoomableImage()
.zoomButtons() // Provides +/- buttons as alternative
// GOOD: Swipe to delete with button
List {
ForEach(items) { item in
ItemRow(item)
.swipeActions {
Button(role: .destructive) {
delete(item)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
// BAD: Required complex gesture with no alternative
// Drawing signature with no "Type name" option
Provide alternatives to motion-based controls (shaking, tilting) per WCAG 2.5.4.
// GOOD: Shake to undo with button alternative
.onShake {
undo()
}
// Also provide:
Button("Undo", action: undo)
// GOOD: Respect motion preferences
if !accessibilityPrefersReducedMotion {
// Use motion-based feature
} else {
// Use static alternative
}
Support user text size preferences (WCAG 1.4.4 - Resize text, 1.4.10 - Reflow).
// iOS - Dynamic Type
Text("Hello")
.font(.body) // Automatically scales with user settings
// Custom fonts with scaling
.font(.custom("MyFont", size: 17, relativeTo: .body))
// Android - Scalable text
<TextView
android:text="Hello"
android:textSize="16sp" /> <!-- Use sp, not dp -->
// Compose
Text(
"Hello",
fontSize = 16.sp // Scales with user settings
)
Text scaling best practices:
Support both portrait and landscape orientations unless a specific orientation is essential (WCAG 1.3.4).
Mobile devices are often used outdoors in bright sunlight, making contrast even more critical.
// iOS - Dark mode support
Color("PrimaryText") // Defined in asset catalog with light/dark variants
Text("Important")
.foregroundColor(.primary) // Automatically adapts
// Android - Dark mode
<resources>
<color name="primaryText">#000000</color>
</resources>
<!-- res/values-night/colors.xml -->
<resources>
<color name="primaryText">#FFFFFF</color>
</resources>
import { View, Text, TouchableOpacity } from 'react-native';
<TouchableOpacity
accessible={true}
accessibilityLabel="Submit form"
accessibilityHint="Double tap to submit"
accessibilityRole="button"
onPress={handleSubmit}>
<Text>Submit</Text>
</TouchableOpacity>
// Hide decorative elements
<Image
source={require('./decoration.png')}
accessibilityElementsHidden={true} // iOS
importantForAccessibility="no" // Android
/>
// Group elements
<View
accessible={true}
accessibilityLabel="Product: Phone, $599, 4.5 stars">
<Image source={productImage} />
<Text>Phone</Text>
<Text>$599</Text>
<Text>4.5 ★</Text>
</View>
// Flutter accessibility
Semantics(
label: 'Submit form',
hint: 'Double tap to submit',
button: true,
child: ElevatedButton(
onPressed: submit,
child: Text('Submit'),
),
)
// Merge semantics
Semantics(
label: '4.5 stars, 120 reviews',
child: Row(
children: [
Icon(Icons.star),
Text('4.5'),
Text('(120 reviews)'),
],
),
)
// Exclude from semantics
ExcludeSemantics(
child: Image.asset('decoration.png'),
)