From 415623870e4b4126dfc8dc041c380dd60d64b371 Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Fri, 15 May 2026 00:17:45 -0700 Subject: [PATCH] Accessibility environment values * `accessibilityEnabled` * `accessibilityInvertColors` * `accessibilityReduceMotion` * `accessibilityReduceTransparency` * `accessibilitySwitchControlEnabled` * `accessibilityVoiceOverEnabled` * `colorSchemeContrast` * `legibilityWeight` --- README.md | 8 + .../SkipUI/Color/ColorSchemeContrast.swift | 17 +- .../AccessibilityEnvironment.swift | 225 ++++++++++++++++++ .../Environment/EnvironmentValues.swift | 81 ++++++- Sources/SkipUI/SkipUI/Text/Font.swift | 1 + 5 files changed, 313 insertions(+), 19 deletions(-) create mode 100644 Sources/SkipUI/SkipUI/Environment/AccessibilityEnvironment.swift diff --git a/README.md b/README.md index c7f7c1dd..6c907861 100644 --- a/README.md +++ b/README.md @@ -2635,14 +2635,22 @@ See the [Skip Showcase app](https://github.com/skiptools/skipapp-showcase) `Text SwiftUI has many built-in environment keys. These keys are defined in `EnvironmentValues` and typically accessed with the `@Environment` property wrapper. In additional to supporting your custom environment keys, SkipUI exposes the following built-in environment keys: +- `accessibilityEnabled` (read-only) +- `accessibilityInvertColors` (read-only) +- `accessibilityReduceMotion` (read-only) +- `accessibilityReduceTransparency` (read-only; Android maps to "reduce blur effects") +- `accessibilitySwitchControlEnabled` (read-only) +- `accessibilityVoiceOverEnabled` (read-only) - `autocorrectionDisabled` (read-only) - `backgroundStyle` +- `colorSchemeContrast` (read-only) - `dismiss` - `font` - `horizontalSizeClass` - `isEnabled` - `isSearching` (read-only) - `layoutDirection` +- `legibilityWeight` (read-only) - `lineLimit` - `locale` - `openURL` diff --git a/Sources/SkipUI/SkipUI/Color/ColorSchemeContrast.swift b/Sources/SkipUI/SkipUI/Color/ColorSchemeContrast.swift index 64765dec..3e3dea2b 100644 --- a/Sources/SkipUI/SkipUI/Color/ColorSchemeContrast.swift +++ b/Sources/SkipUI/SkipUI/Color/ColorSchemeContrast.swift @@ -1,6 +1,6 @@ // Copyright 2023–2026 Skip // SPDX-License-Identifier: MPL-2.0 -/* +#if !SKIP_BRIDGE /// The contrast between the app's foreground and background colors. /// /// You receive a contrast value when you read the @@ -21,15 +21,10 @@ /// Accessibility > Display & Text Size in the Settings app on iOS. /// Your app can't override the user's choice. @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public enum ColorSchemeContrast : CaseIterable, Sendable { - - /// SkipUI displays views with standard contrast between the app's - /// foreground and background colors. - case standard - - /// SkipUI displays views with increased contrast between the app's - /// foreground and background colors. - case increased +// SKIP @bridgeMembers +public enum ColorSchemeContrast : Int, CaseIterable, Sendable { + case standard = 0 // For bridging + case increased = 1 // For bridging @@ -49,4 +44,4 @@ extension ColorSchemeContrast : Equatable { @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) extension ColorSchemeContrast : Hashable { } -*/ +#endif \ No newline at end of file diff --git a/Sources/SkipUI/SkipUI/Environment/AccessibilityEnvironment.swift b/Sources/SkipUI/SkipUI/Environment/AccessibilityEnvironment.swift new file mode 100644 index 00000000..c69ca14a --- /dev/null +++ b/Sources/SkipUI/SkipUI/Environment/AccessibilityEnvironment.swift @@ -0,0 +1,225 @@ +// Copyright 2025–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if !SKIP_BRIDGE +import Foundation +#if SKIP +import android.accessibilityservice.AccessibilityServiceInfo +import android.app.Activity +import android.app.Application +import android.app.UiModeManager +import android.content.Context +import android.database.ContentObserver +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.view.accessibility.AccessibilityManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +#endif + +#if SKIP +private let disableWindowBlursSetting = "disable_window_blurs" +private let highTextContrastEnabledSetting = "high_text_contrast_enabled" + +/// Observes Android accessibility settings and exposes per-value Compose state. +final class AccessibilityEnvironment: ContentObserver { + private static let _shared = AccessibilityEnvironment() + + static var shared: AccessibilityEnvironment { + _shared.installObserversIfNeeded() + return _shared + } + + private let enabled: MutableState = mutableStateOf(false) + private let invertColors: MutableState = mutableStateOf(false) + private let reduceMotion: MutableState = mutableStateOf(false) + private let reduceTransparency: MutableState = mutableStateOf(false) + private let switchControlEnabled: MutableState = mutableStateOf(false) + private let voiceOverEnabled: MutableState = mutableStateOf(false) + private let colorSchemeContrast: MutableState = mutableStateOf(ColorSchemeContrast.standard) + + private var observersInstalled = false + + private let appContext: android.content.Context + private let accessibilityManager: AccessibilityManager + private let uiModeManager: UiModeManager + private let contentResolver: android.content.ContentResolver + + private init() { + let context = ProcessInfo.processInfo.androidContext.applicationContext + appContext = context + accessibilityManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as! AccessibilityManager + uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as! UiModeManager + contentResolver = context.contentResolver + super.init(Handler(Looper.getMainLooper())) + } + + @Composable func accessibilityEnabled() -> Bool { enabled.value } + @Composable func accessibilityInvertColors() -> Bool { invertColors.value } + @Composable func accessibilityReduceMotion() -> Bool { reduceMotion.value } + @Composable func accessibilityReduceTransparency() -> Bool { reduceTransparency.value } + @Composable func accessibilitySwitchControlEnabled() -> Bool { switchControlEnabled.value } + @Composable func accessibilityVoiceOverEnabled() -> Bool { voiceOverEnabled.value } + @Composable func colorSchemeContrast() -> ColorSchemeContrast { colorSchemeContrast.value } + + private func refreshEnabled() { + enabled.value = accessibilityManager.isEnabled + refreshVoiceOverEnabled() + refreshSwitchControlEnabled() + } + + private func refreshInvertColors() { + invertColors.value = Settings.Secure.getInt( + contentResolver, + Settings.Secure.ACCESSIBILITY_DISPLAY_INVERSION_ENABLED, + 0 + ) == 1 + } + + private func refreshReduceMotion() { + reduceMotion.value = Settings.Global.getFloat( + contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + Float(1.0) + ) == Float(0.0) + } + + private func refreshReduceTransparency() { + reduceTransparency.value = Settings.Global.getInt(contentResolver, disableWindowBlursSetting, 0) == 1 + } + + private func refreshSwitchControlEnabled() { + let am = accessibilityManager + var switchEnabled = false + if am.isEnabled { + for service in am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_GENERIC) { + if let name = service.settingsActivityName, name.lowercased().contains("switchaccess") { + switchEnabled = true + break + } + } + } + switchControlEnabled.value = switchEnabled + } + + private func refreshVoiceOverEnabled() { + let am = accessibilityManager + voiceOverEnabled.value = am.isEnabled && am.isTouchExplorationEnabled + } + + private func refreshColorSchemeContrast() { + if Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE { + let contrast = uiModeManager.getContrast() + colorSchemeContrast.value = contrast > Float(0.0) + ? ColorSchemeContrast.increased + : ColorSchemeContrast.standard + } else { + let highContrastTextEnabled = + Settings.Secure.getInt(contentResolver, highTextContrastEnabledSetting, 0) == 1 + colorSchemeContrast.value = highContrastTextEnabled + ? ColorSchemeContrast.increased + : ColorSchemeContrast.standard + } + } + + private func refreshAll() { + refreshEnabled() + refreshInvertColors() + refreshReduceMotion() + refreshReduceTransparency() + refreshColorSchemeContrast() + } + + private func installObserversIfNeeded() { + if observersInstalled { + return + } + observersInstalled = true + refreshAll() + + let resolver = contentResolver + let invertUri = Settings.Secure.getUriFor(Settings.Secure.ACCESSIBILITY_DISPLAY_INVERSION_ENABLED) + let motionUri = Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE) + let blurUri = Settings.Global.getUriFor(disableWindowBlursSetting) + let secureUri = Settings.Secure.getUriFor("secure") + + resolver.registerContentObserver(motionUri, false, self) + resolver.registerContentObserver(invertUri, true, self) + resolver.registerContentObserver(blurUri, false, self) + resolver.registerContentObserver(secureUri, true, self) + + let am = accessibilityManager + am.addAccessibilityStateChangeListener( + AccessibilityManager.AccessibilityStateChangeListener { _ in self.refreshEnabled() } + ) + am.addTouchExplorationStateChangeListener( + AccessibilityManager.TouchExplorationStateChangeListener { _ in self.refreshVoiceOverEnabled() } + ) + if Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU { + am.addAccessibilityServicesStateChangeListener( + AccessibilityManager.AccessibilityServicesStateChangeListener { _ in + self.refreshSwitchControlEnabled() + } + ) + } + if Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE { + uiModeManager.addContrastChangeListener( + appContext.mainExecutor, + UiModeManager.ContrastChangeListener { _ in + self.refreshColorSchemeContrast() + } + ) + } + + if let application = appContext as? Application { + application.registerActivityLifecycleCallbacks(self) + } + } +} + +extension AccessibilityEnvironment { + override func onChange(selfChange: Bool) { + refreshAll() + } + + override func onChange(selfChange: Bool, uri: Uri?) { + guard let uri = uri else { + refreshAll() + return + } + let motionUri = Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE) + let invertUri = Settings.Secure.getUriFor(Settings.Secure.ACCESSIBILITY_DISPLAY_INVERSION_ENABLED) + let blurUri = Settings.Global.getUriFor(disableWindowBlursSetting) + let contrastUri = Settings.Secure.getUriFor(highTextContrastEnabledSetting) + if uri == motionUri { + refreshReduceMotion() + } else if uri == invertUri { + refreshInvertColors() + } else if uri == blurUri { + refreshReduceTransparency() + } else if uri == contrastUri { + refreshColorSchemeContrast() + } else { + refreshAll() + } + } +} + +extension AccessibilityEnvironment: Application.ActivityLifecycleCallbacks { + override func onActivityResumed(activity: Activity) { + refreshAll() + } + + override func onActivityCreated(activity: Activity, savedInstanceState: android.os.Bundle?) {} + override func onActivityStarted(activity: Activity) {} + override func onActivityPaused(activity: Activity) {} + override func onActivityStopped(activity: Activity) {} + override func onActivitySaveInstanceState(activity: Activity, outState: android.os.Bundle) {} + override func onActivityDestroyed(activity: Activity) {} +} +#endif + +#endif diff --git a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift index 5f83c841..1202b513 100644 --- a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift +++ b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift @@ -8,6 +8,7 @@ import Observation #if SKIP import android.content.res.Configuration +import android.os.Build import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.text.KeyboardOptions @@ -287,6 +288,20 @@ extension EnvironmentValues { // NOTE: We also maintain equivalent code in SkipSwiftUI.EnvironmentValues. // It would be nice to come up with a better way to do this... switch key { + case "accessibilityEnabled": + return EnvironmentSupport(builtinValue: AccessibilityEnvironment.shared.accessibilityEnabled()) + case "accessibilityInvertColors": + return EnvironmentSupport(builtinValue: AccessibilityEnvironment.shared.accessibilityInvertColors()) + case "accessibilityReduceMotion": + return EnvironmentSupport(builtinValue: AccessibilityEnvironment.shared.accessibilityReduceMotion()) + case "accessibilityReduceTransparency": + return EnvironmentSupport(builtinValue: AccessibilityEnvironment.shared.accessibilityReduceTransparency()) + case "accessibilitySwitchControlEnabled": + return EnvironmentSupport(builtinValue: AccessibilityEnvironment.shared.accessibilitySwitchControlEnabled()) + case "accessibilityVoiceOverEnabled": + return EnvironmentSupport(builtinValue: AccessibilityEnvironment.shared.accessibilityVoiceOverEnabled()) + case "colorSchemeContrast": + return EnvironmentSupport(builtinValue: AccessibilityEnvironment.shared.colorSchemeContrast().rawValue) case "autocorrectionDisabled": return EnvironmentSupport(builtinValue: autocorrectionDisabled) case "backgroundStyle": @@ -305,6 +320,8 @@ extension EnvironmentValues { return EnvironmentSupport(builtinValue: isSearching) case "layoutDirection": return EnvironmentSupport(builtinValue: layoutDirection.rawValue) + case "legibilityWeight": + return EnvironmentSupport(builtinValue: legibilityWeight) case "lineLimit": return EnvironmentSupport(builtinValue: lineLimit) case "locale": @@ -330,6 +347,20 @@ extension EnvironmentValues { private func setBuiltinBridged(key: String, value: EnvironmentSupport?) -> Bool { switch key { + case "accessibilityEnabled": + return false + case "accessibilityInvertColors": + return false + case "accessibilityReduceMotion": + return false + case "accessibilityReduceTransparency": + return false + case "accessibilitySwitchControlEnabled": + return false + case "accessibilityVoiceOverEnabled": + return false + case "colorSchemeContrast": + return false case "autocorrectionDisabled": return false case "backgroundStyle": @@ -355,6 +386,8 @@ extension EnvironmentValues { let layoutDirection: LayoutDirection = rawValue == nil ? .leftToRight : LayoutDirection(rawValue: rawValue!) ?? .leftToRight setlayoutDirection(layoutDirection) return true + case "legibilityWeight": + return false case "lineLimit": setlineLimit(value?.builtinValue as? Int) return true @@ -512,21 +545,54 @@ extension EnvironmentValues { UserInterfaceSizeClass.fromWindowHeightSizeClass(currentWindowAdaptiveInfo().windowSizeClass.windowHeightSizeClass) } + public var accessibilityEnabled: Bool { + return AccessibilityEnvironment.shared.accessibilityEnabled() + } + + public var accessibilityInvertColors: Bool { + return AccessibilityEnvironment.shared.accessibilityInvertColors() + } + + public var accessibilityReduceMotion: Bool { + return AccessibilityEnvironment.shared.accessibilityReduceMotion() + } + + public var accessibilityReduceTransparency: Bool { + return AccessibilityEnvironment.shared.accessibilityReduceTransparency() + } + + public var accessibilitySwitchControlEnabled: Bool { + return AccessibilityEnvironment.shared.accessibilitySwitchControlEnabled() + } + + public var accessibilityVoiceOverEnabled: Bool { + return AccessibilityEnvironment.shared.accessibilityVoiceOverEnabled() + } + + public var colorSchemeContrast: ColorSchemeContrast { + return AccessibilityEnvironment.shared.colorSchemeContrast() + } + + public var legibilityWeight: LegibilityWeight? { + if Build.VERSION.SDK_INT >= Build.VERSION_CODES.S { + let adjustment = LocalConfiguration.current.fontWeightAdjustment + if adjustment == Configuration.FONT_WEIGHT_ADJUSTMENT_UNDEFINED { + return nil + } + return adjustment == 0 ? .regular : .bold + } + return nil + } + /* Not yet supported + var accessibilityAssistiveAccessEnabled var accessibilityDimFlashingLights: Bool var accessibilityDifferentiateWithoutColor: Bool - var accessibilityEnabled: Bool - var accessibilityInvertColors: Bool var accessibilityLargeContentViewerEnabled: Bool var accessibilityPlayAnimatedImages: Bool var accessibilityPrefersHeadAnchorAlternative: Bool var accessibilityQuickActionsEnabled: Bool - var accessibilityReduceMotion: Bool - var accessibilityReduceTransparency: Bool var accessibilityShowButtonShapes: Bool - var accessibilitySwitchControlEnabled: Bool - var accessibilityVoiceOverEnabled: Bool - var legibilityWeight: LegibilityWeight? var dismissSearch: DismissSearchAction var dismissWindow: DismissWindowAction @@ -549,7 +615,6 @@ extension EnvironmentValues { var menuIndicatorVisibility: Visibility var menuOrder: MenuOrder var searchSuggestionsPlacement: SearchSuggestionsPlacement - var colorSchemeContrast: ColorSchemeContrast var displayScale: CGFloat var imageScale: Image.Scale var pixelLength: CGFloat diff --git a/Sources/SkipUI/SkipUI/Text/Font.swift b/Sources/SkipUI/SkipUI/Text/Font.swift index ba5d296b..7a463450 100644 --- a/Sources/SkipUI/SkipUI/Text/Font.swift +++ b/Sources/SkipUI/SkipUI/Text/Font.swift @@ -485,6 +485,7 @@ public struct Font : Hashable { #endif } +// SKIP @bridgeMembers public enum LegibilityWeight : Hashable { case regular case bold