Skip to content

Commit 4cdc464

Browse files
authored
Merge pull request #59 from PureSwift/feature/accessibility
Add accessibility modifiers
2 parents 022b05e + 4d12c82 commit 4cdc464

5 files changed

Lines changed: 222 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
//
2+
// Accessibility.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Accessibility describes a view to assistive technology rather than changing
6+
// how it looks, so every one of these folds into Compose semantics: a label
7+
// becomes the content description, a value becomes the state description,
8+
// traits become a role, and hiding clears the subtree's semantics outright.
9+
//
10+
11+
/// Roles that change how assistive technology announces a view.
12+
public struct AccessibilityTraits: OptionSet, Sendable {
13+
14+
public let rawValue: Int
15+
public init(rawValue: Int) { self.rawValue = rawValue }
16+
17+
public static let isButton = AccessibilityTraits(rawValue: 1 << 0)
18+
public static let isHeader = AccessibilityTraits(rawValue: 1 << 1)
19+
public static let isSelected = AccessibilityTraits(rawValue: 1 << 2)
20+
public static let isImage = AccessibilityTraits(rawValue: 1 << 3)
21+
22+
/// The names the interpreter maps to Compose semantics.
23+
internal var names: [String] {
24+
var names: [String] = []
25+
if contains(.isButton) { names.append("button") }
26+
if contains(.isHeader) { names.append("header") }
27+
if contains(.isSelected) { names.append("selected") }
28+
if contains(.isImage) { names.append("image") }
29+
return names
30+
}
31+
}
32+
33+
public struct _AccessibilityModifier: RenderModifier {
34+
let kind: String
35+
let args: [String: PropValue]
36+
public var _modifierNode: ModifierNode { ModifierNode(kind: kind, args: args) }
37+
}
38+
39+
public extension View {
40+
41+
/// The description assistive technology reads for this view.
42+
func accessibilityLabel<S: StringProtocol>(_ label: S) -> ModifiedContent<Self, _AccessibilityModifier> {
43+
modifier(_AccessibilityModifier(kind: "accessibilityLabel", args: ["text": .string(String(label))]))
44+
}
45+
46+
/// The view's current value, announced after its label.
47+
func accessibilityValue<S: StringProtocol>(_ value: S) -> ModifiedContent<Self, _AccessibilityModifier> {
48+
modifier(_AccessibilityModifier(kind: "accessibilityValue", args: ["text": .string(String(value))]))
49+
}
50+
51+
/// Removes the view, and everything inside it, from the accessibility tree.
52+
func accessibilityHidden(_ hidden: Bool) -> ModifiedContent<Self, _AccessibilityModifier> {
53+
modifier(_AccessibilityModifier(kind: "accessibilityHidden", args: ["value": .bool(hidden)]))
54+
}
55+
56+
func accessibilityAddTraits(_ traits: AccessibilityTraits) -> ModifiedContent<Self, _AccessibilityModifier> {
57+
modifier(_AccessibilityModifier(
58+
kind: "accessibilityAddTraits",
59+
args: ["traits": .array(traits.names.map { .string($0) })]
60+
))
61+
}
62+
63+
/// A stable handle for UI tests. Never announced.
64+
func accessibilityIdentifier<S: StringProtocol>(_ identifier: S) -> ModifiedContent<Self, _AccessibilityModifier> {
65+
modifier(_AccessibilityModifier(kind: "accessibilityIdentifier", args: ["id": .string(String(identifier))]))
66+
}
67+
68+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,3 +737,60 @@ struct PreferenceTests {
737737
#expect(outer == 70) // the inner reduction reached the outer one
738738
}
739739
}
740+
741+
@Suite("Accessibility")
742+
struct AccessibilityTests {
743+
744+
private func arg(_ node: RenderNode, _ kind: String, _ key: String) -> PropValue? {
745+
node.modifiers.first { $0.kind == kind }?.args[key]
746+
}
747+
748+
@Test("Label, value, and identifier emit their text")
749+
func describes() {
750+
let node = ViewHost(
751+
Text("7")
752+
.accessibilityLabel("Unread messages")
753+
.accessibilityValue("7 items")
754+
.accessibilityIdentifier("inbox-count")
755+
).evaluate()
756+
#expect(arg(node, "accessibilityLabel", "text") == .string("Unread messages"))
757+
#expect(arg(node, "accessibilityValue", "text") == .string("7 items"))
758+
#expect(arg(node, "accessibilityIdentifier", "id") == .string("inbox-count"))
759+
}
760+
761+
@Test("Hiding is explicit in both directions")
762+
func hidden() {
763+
let hidden = ViewHost(Text("decorative").accessibilityHidden(true)).evaluate()
764+
#expect(arg(hidden, "accessibilityHidden", "value") == .bool(true))
765+
// false must still emit — it has to be able to override an inherited hide
766+
let shown = ViewHost(Text("visible").accessibilityHidden(false)).evaluate()
767+
#expect(arg(shown, "accessibilityHidden", "value") == .bool(false))
768+
}
769+
770+
@Test("Traits emit as a set of names")
771+
func traits() {
772+
let node = ViewHost(
773+
Text("Chapter").accessibilityAddTraits([.isHeader, .isButton])
774+
).evaluate()
775+
guard case .array(let names)? = arg(node, "accessibilityAddTraits", "traits") else {
776+
Issue.record("missing traits"); return
777+
}
778+
#expect(names.contains(.string("header")))
779+
#expect(names.contains(.string("button")))
780+
#expect(!names.contains(.string("selected")))
781+
}
782+
783+
784+
@Test("Accessibility describes without disturbing the view")
785+
func nonVisual() {
786+
// the node type, props, and children must be identical with and without
787+
// a description — these modifiers only add semantics
788+
let plain = ViewHost(VStack { Text("hello") }).evaluate()
789+
let described = ViewHost(
790+
VStack { Text("hello") }.accessibilityLabel("greeting")
791+
).evaluate()
792+
#expect(plain.type == described.type)
793+
#expect(plain.children.count == described.children.count)
794+
#expect(firstTextString(plain) == firstTextString(described))
795+
}
796+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct AccessibilityPlayground: View {
8+
9+
@State private var unread = 7
10+
11+
var body: some View {
12+
ScrollView {
13+
VStack(alignment: .leading, spacing: 0) {
14+
Example("accessibilityLabel") {
15+
// reads as "Unread messages", not "7"
16+
Text("7")
17+
.accessibilityLabel("Unread messages")
18+
}
19+
Example("accessibilityValue") {
20+
Text("Volume")
21+
.accessibilityLabel("Volume")
22+
.accessibilityValue("30 percent")
23+
}
24+
Example("accessibilityHidden") {
25+
VStack(alignment: .leading, spacing: 8) {
26+
Text("kept in the tree")
27+
Text("decorative-flourish")
28+
.accessibilityHidden(true)
29+
}
30+
}
31+
Example("accessibilityAddTraits") {
32+
VStack(alignment: .leading, spacing: 8) {
33+
Text("Chapter One")
34+
.accessibilityAddTraits(.isHeader)
35+
Text("Tap to continue")
36+
.accessibilityAddTraits(.isButton)
37+
}
38+
}
39+
Example("accessibilityIdentifier") {
40+
Text("Checkout")
41+
.accessibilityIdentifier("checkout-button")
42+
}
43+
}
44+
}
45+
.navigationTitle("Accessibility")
46+
}
47+
}

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ struct CatalogEntry: Identifiable {
6161
CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())),
6262
CatalogEntry(id: "sheet", title: "Sheet", screen: AnyCatalogScreen(SheetPlayground())),
6363
CatalogEntry(id: "alert", title: "Alert", screen: AnyCatalogScreen(AlertPlayground())),
64+
CatalogEntry(id: "accessibility", title: "Accessibility", screen: AnyCatalogScreen(AccessibilityPlayground())),
6465
CatalogEntry(id: "state", title: "State", screen: AnyCatalogScreen(StatePlayground())),
6566
CatalogEntry(id: "preference", title: "Preferences", screen: AnyCatalogScreen(PreferencePlayground())),
6667
CatalogEntry(id: "environment", title: "Environment", screen: AnyCatalogScreen(EnvironmentPlayground())),

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@ import androidx.compose.ui.draw.rotate
149149
import androidx.compose.ui.draw.scale
150150
import androidx.compose.ui.draw.shadow
151151
import androidx.compose.ui.graphics.Brush
152+
import androidx.compose.ui.semantics.semantics
153+
import androidx.compose.ui.semantics.clearAndSetSemantics
154+
import androidx.compose.ui.semantics.contentDescription
155+
import androidx.compose.ui.semantics.stateDescription
156+
import androidx.compose.ui.semantics.heading
157+
import androidx.compose.ui.semantics.selected
158+
import androidx.compose.ui.semantics.Role
159+
import androidx.compose.ui.semantics.role
160+
import androidx.compose.ui.platform.testTag
152161
import androidx.compose.ui.graphics.Color
153162
import androidx.compose.ui.layout.ContentScale
154163
import androidx.compose.ui.graphics.ImageBitmap
@@ -1448,6 +1457,44 @@ internal fun ViewNode.composeModifiers(): Modifier {
14481457
}
14491458
}
14501459

1460+
// Accessibility folds into Compose semantics rather than changing
1461+
// any visual property.
1462+
"accessibilityLabel" -> {
1463+
val text = entry.args.string("text")
1464+
if (text == null) modifier else modifier.semantics { contentDescription = text }
1465+
}
1466+
1467+
"accessibilityValue" -> {
1468+
val text = entry.args.string("text")
1469+
if (text == null) modifier else modifier.semantics { stateDescription = text }
1470+
}
1471+
1472+
// clearAndSetSemantics drops this node AND its subtree from the
1473+
// tree, which is what hiding has to mean for a container.
1474+
"accessibilityHidden" -> {
1475+
val hidden = entry.args.string("value") == "true"
1476+
if (hidden) modifier.clearAndSetSemantics { } else modifier
1477+
}
1478+
1479+
"accessibilityAddTraits" -> {
1480+
val traits = (entry.args["traits"] as? kotlinx.serialization.json.JsonArray)
1481+
?.mapNotNull { (it as? kotlinx.serialization.json.JsonPrimitive)?.content }
1482+
.orEmpty()
1483+
modifier.semantics {
1484+
if (traits.contains("button")) role = Role.Button
1485+
if (traits.contains("image")) role = Role.Image
1486+
if (traits.contains("header")) heading()
1487+
if (traits.contains("selected")) selected = true
1488+
}
1489+
}
1490+
1491+
// A test handle, never announced.
1492+
"accessibilityIdentifier" -> {
1493+
val id = entry.args.string("id")
1494+
if (id == null) modifier else modifier.testTag(id)
1495+
}
1496+
1497+
14511498
// Dim disabled content; controls also drop interactivity via their
14521499
// own `enabled` parameter (e.g. Button).
14531500
"disabled" -> {
@@ -1476,6 +1523,8 @@ private val KNOWN_MODIFIER_KINDS = setOf(
14761523
"tint", "onAppear", "onDisappear", "task", "onChange", "animation", "tag", "tabItem",
14771524
"transition", "focused", "longPress", "drag", "contentMode", "progressViewStyle",
14781525
"buttonStyle", "pickerStyle", "toggleStyle", "textFieldStyle",
1526+
"accessibilityLabel", "accessibilityValue", "accessibilityHidden",
1527+
"accessibilityAddTraits", "accessibilityIdentifier",
14791528
)
14801529

14811530
// Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded

0 commit comments

Comments
 (0)