Files
Reflect/Shared/Utilities/AccessibilityHelpers.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

169 lines
5.1 KiB
Swift

//
// AccessibilityHelpers.swift
// Reflect
//
// Accessibility utilities for supporting VoiceOver, Dynamic Type, and Reduce Motion.
//
import SwiftUI
// MARK: - Reduce Motion Support
/// Environment key for accessing reduce motion preference
struct ReduceMotionKey: EnvironmentKey {
static let defaultValue: Bool = false
}
extension EnvironmentValues {
var reduceMotion: Bool {
get { self[ReduceMotionKey.self] }
set { self[ReduceMotionKey.self] = newValue }
}
}
extension View {
/// Wraps content in withAnimation respecting reduce motion
func withAccessibleAnimation<V: Equatable>(_ animation: Animation? = .default, value: V, action: @escaping () -> Void) -> some View {
self.onChange(of: value) { _, _ in
if UIAccessibility.isReduceMotionEnabled {
action()
} else {
withAnimation(animation) {
action()
}
}
}
}
}
// MARK: - Accessibility Helpers
extension View {
/// Adds accessibility label with optional hint
func accessibleMoodCell(mood: Mood, date: Date) -> some View {
let dateString = DateFormattingCache.shared.string(for: date, format: .dateMedium)
return self
.accessibilityLabel("\(mood.strValue) on \(dateString)")
.accessibilityHint("Double tap to edit mood")
}
/// Makes a button accessible with custom label
func accessibleButton(label: String, hint: String? = nil) -> some View {
self
.accessibilityLabel(label)
.accessibilityHint(hint ?? "")
.accessibilityAddTraits(.isButton)
}
/// Groups related elements for VoiceOver
func accessibilityGrouped(label: String) -> some View {
self
.accessibilityElement(children: .combine)
.accessibilityLabel(label)
}
}
// MARK: - Dynamic Type Support
extension Font {
/// Returns a scalable font that respects Dynamic Type
static func scalable(_ style: Font.TextStyle, weight: Font.Weight = .regular) -> Font {
Font.system(style, design: .rounded).weight(weight)
}
}
/// Property wrapper for scaled metrics that respect Dynamic Type
@propertyWrapper
struct ScaledValue: DynamicProperty {
@ScaledMetric private var scaledValue: CGFloat
var wrappedValue: CGFloat { scaledValue }
init(wrappedValue: CGFloat, relativeTo textStyle: Font.TextStyle = .body) {
_scaledValue = ScaledMetric(wrappedValue: wrappedValue, relativeTo: textStyle)
}
}
extension View {
/// Ensures minimum touch target size for accessibility (44x44 points)
func accessibleTouchTarget() -> some View {
self.frame(minWidth: 44, minHeight: 44)
}
}
// MARK: - Accessibility Announcements
struct AccessibilityAnnouncement {
/// Announces a message to VoiceOver users
static func announce(_ message: String) {
UIAccessibility.post(notification: .announcement, argument: message)
}
/// Notifies VoiceOver that screen content has changed
static func screenChanged() {
UIAccessibility.post(notification: .screenChanged, argument: nil)
}
/// Notifies VoiceOver that layout has changed
static func layoutChanged(focusElement: Any? = nil) {
UIAccessibility.post(notification: .layoutChanged, argument: focusElement)
}
}
// MARK: - Reduce Motion View Modifier
/// View modifier that provides alternative content when Reduce Motion is enabled
struct ReduceMotionContent<Reduced: View>: ViewModifier {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let reducedContent: () -> Reduced
func body(content: Content) -> some View {
if reduceMotion {
reducedContent()
} else {
content
}
}
}
extension View {
/// Provides alternative content when Reduce Motion is enabled
func reduceMotionAlternative<V: View>(@ViewBuilder _ reduced: @escaping () -> V) -> some View {
modifier(ReduceMotionContent(reducedContent: reduced))
}
/// Conditionally applies animation based on Reduce Motion setting
func animationIfAllowed<V: Equatable>(_ animation: Animation?, value: V) -> some View {
modifier(ConditionalAnimationModifier(animation: animation, value: value))
}
}
struct ConditionalAnimationModifier<V: Equatable>: ViewModifier {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let animation: Animation?
let value: V
func body(content: Content) -> some View {
if reduceMotion {
content
} else {
content.animation(animation, value: value)
}
}
}
// MARK: - VoiceOver Detection
extension View {
/// Runs an action when VoiceOver is running
func onVoiceOverChange(perform action: @escaping (Bool) -> Void) -> some View {
self.onReceive(NotificationCenter.default.publisher(for: UIAccessibility.voiceOverStatusDidChangeNotification)) { _ in
action(UIAccessibility.isVoiceOverRunning)
}
.onAppear {
action(UIAccessibility.isVoiceOverRunning)
}
}
}