diff --git a/Configurations/Shared/basic/OpenSwiftUI.xcconfig b/Configurations/Shared/basic/OpenSwiftUI.xcconfig index 6ac157056..0d2959dbd 100644 --- a/Configurations/Shared/basic/OpenSwiftUI.xcconfig +++ b/Configurations/Shared/basic/OpenSwiftUI.xcconfig @@ -2,4 +2,4 @@ GCC_PREPROCESSOR_DEFINITIONS_OPENSWIFTUI = OPENSWIFTUI=1 SWIFT_ACTIVE_COMPILATION_CONDITIONS_OPENSWIFTUI = OPENSWIFTUI -OPENSWIFTUI_AVAILABILITY_MACRO = "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v1_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v1_4:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v2_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_macOS_v2_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v2_1:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v2_3:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v3_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v3_2:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v3_4:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v4_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v4_1:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v4_4:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_1:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_2:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_4:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v6_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v7_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" +OPENSWIFTUI_AVAILABILITY_MACRO = "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v1_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v1_4:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v2_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_macOS_v2_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v2_1:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v2_3:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v3_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v3_2:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v3_4:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v4_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v4_1:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v4_4:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_1:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_2:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_4:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_5:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v6_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v7_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" diff --git a/Configurations/Shared/basic/SwiftUI.xcconfig b/Configurations/Shared/basic/SwiftUI.xcconfig index d739ad347..6b6539a73 100644 --- a/Configurations/Shared/basic/SwiftUI.xcconfig +++ b/Configurations/Shared/basic/SwiftUI.xcconfig @@ -1 +1 @@ -OPENSWIFTUI_AVAILABILITY_MACRO = "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v1_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v1_4:iOS 13.4, macOS 10.15.4, tvOS 13.4, watchOS 6.2" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v2_0:iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_macOS_v2_0:macOS 11.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v2_1:iOS 14.2, macOS 11.0, tvOS 14.1, watchOS 7.1" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v2_3:iOS 14.5, macOS 11.3, tvOS 14.5, watchOS 7.4" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v3_0:iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v3_2:iOS 15.2, macOS 12.1, tvOS 15.2, watchOS 8.3" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v3_4:iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 8.5" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v4_0:iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v4_1:iOS 16.1, macOS 13.0, tvOS 16.1, watchOS 9.1" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v4_4:iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_0:iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_1:iOS 17.1, macOS 14.1, tvOS 17.1, watchOS 10.1, visionOS 1.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_2:iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_4:iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, visionOS 1.1" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v6_0:iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v7_0:iOS 19.0, macOS 16.0, tvOS 19.0, watchOS 12.0, visionOS 3.0" +OPENSWIFTUI_AVAILABILITY_MACRO = "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v1_0:iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v1_4:iOS 13.4, macOS 10.15.4, tvOS 13.4, watchOS 6.2" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v2_0:iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_macOS_v2_0:macOS 11.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v2_1:iOS 14.2, macOS 11.0, tvOS 14.1, watchOS 7.1" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v2_3:iOS 14.5, macOS 11.3, tvOS 14.5, watchOS 7.4" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v3_0:iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v3_2:iOS 15.2, macOS 12.1, tvOS 15.2, watchOS 8.3" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v3_4:iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 8.5" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v4_0:iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v4_1:iOS 16.1, macOS 13.0, tvOS 16.1, watchOS 9.1" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v4_4:iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_0:iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_1:iOS 17.1, macOS 14.1, tvOS 17.1, watchOS 10.1, visionOS 1.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_2:iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_4:iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, visionOS 1.1" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v5_5:iOS 17.5, macOS 14.5, tvOS 17.5, watchOS 10.5, visionOS 1.2" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v6_0:iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0" "-Xfrontend" "-enable-experimental-feature" "-Xfrontend" "AvailabilityMacro=OpenSwiftUI_v7_0:iOS 19.0, macOS 16.0, tvOS 19.0, watchOS 12.0, visionOS 3.0" diff --git a/Package.resolved b/Package.resolved index 0b9c15f89..0364f348b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8425ef2c9fa27534027824da9a10246f9b735e1b4d0ffc21363e1d9bfdf98930", + "originHash" : "373d4af9c37ac358f7c48ef2ced6e6c83689073755afce25ca6121fd7de0cce7", "pins" : [ { "identity" : "darwinprivateframeworks", diff --git a/Package.swift b/Package.swift index a36634244..c31e4eec1 100644 --- a/Package.swift +++ b/Package.swift @@ -469,6 +469,7 @@ extension [SwiftSetting] { .enableExperimentalFeature("AvailabilityMacro=OpenSwiftUI_v5_1:\(ignoreAvailability ? minimumVersion : "iOS 17.1, macOS 14.1, tvOS 17.1, watchOS 10.1, visionOS 1.0")"), .enableExperimentalFeature("AvailabilityMacro=OpenSwiftUI_v5_2:\(ignoreAvailability ? minimumVersion : "iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.0")"), .enableExperimentalFeature("AvailabilityMacro=OpenSwiftUI_v5_4:\(ignoreAvailability ? minimumVersion : "iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, visionOS 1.1")"), + .enableExperimentalFeature("AvailabilityMacro=OpenSwiftUI_v5_5:\(ignoreAvailability ? minimumVersion : "iOS 17.5, macOS 14.5, tvOS 17.5, watchOS 10.5, visionOS 1.2")"), .enableExperimentalFeature("AvailabilityMacro=OpenSwiftUI_v6_0:\(ignoreAvailability ? minimumVersion : "iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0")"), .enableExperimentalFeature("AvailabilityMacro=OpenSwiftUI_v7_0:\(ignoreAvailability ? minimumVersion : "iOS 19.0, macOS 16.0, tvOS 19.0, watchOS 12.0, visionOS 3.0")"), .enableExperimentalFeature("AvailabilityMacro=_distantFuture:iOS 99.0, macOS 99.0, tvOS 99.0, watchOS 99.0, visionOS 99.0"), diff --git a/Sources/OpenSwiftUI/Integration/Hosting/UIKit/View/UIHostingView.swift b/Sources/OpenSwiftUI/Integration/Hosting/UIKit/View/UIHostingView.swift index f19b3953d..72fe34c66 100644 --- a/Sources/OpenSwiftUI/Integration/Hosting/UIKit/View/UIHostingView.swift +++ b/Sources/OpenSwiftUI/Integration/Hosting/UIKit/View/UIHostingView.swift @@ -124,7 +124,11 @@ open class _UIHostingView: UIView, XcodeViewDebugDataProvider where Con didChangeColorScheme(from: oldValue) } } - + + // TODO + + let feedbackCache: UIKitSensoryFeedbackCache = .init() + // TODO // var currentAccessibilityFocusStore: AccessibilityFocusStore = .init() @@ -177,7 +181,7 @@ open class _UIHostingView: UIView, XcodeViewDebugDataProvider where Con base.setupNotifications() } // RepresentableContextValues.current = - + feedbackCache.host = self // TODO HostingViewRegistry.shared.add(self) Update.end() @@ -512,6 +516,8 @@ extension _UIHostingView: ViewRendererHost { if let displayGamut = DisplayGamut(rawValue: traitCollection.displayGamut.rawValue) { environment.displayGamut = displayGamut } + // TODO + environment.feedbackCache = feedbackCache viewGraph.setEnvironment(environment) } diff --git a/Sources/OpenSwiftUI/View/Control/SensoryFeedback/AppKit/AppKitFeedbackImplementation.swift b/Sources/OpenSwiftUI/View/Control/SensoryFeedback/AppKit/AppKitFeedbackImplementation.swift new file mode 100644 index 000000000..dc5cccf12 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/SensoryFeedback/AppKit/AppKitFeedbackImplementation.swift @@ -0,0 +1,54 @@ +// +// AppKitFeedbackImplementation.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +#if os(macOS) +import AppKit +import OpenSwiftUICore + +// MARK: - View + platformSensoryFeedback + +extension View { + nonisolated func platformSensoryFeedback( + _ base: Base + ) -> some View where Base: SensoryFeedbackGeneratorModifier { + modifier(base) + } +} + +// MARK: - HapticFeedbackManagerImplementation + +struct HapticFeedbackManagerImplementation: PlatformSensoryFeedback { + var pattern: NSHapticFeedbackManager.FeedbackPattern + + func setUp() { + _openSwiftUIEmptyStub() + } + + func tearDown() { + _openSwiftUIEmptyStub() + } + + func generate() { + NSHapticFeedbackManager.defaultPerformer.perform( + pattern, + performanceTime: .default + ) + } +} + +// MARK: - FeedbackRequestContext + +struct FeedbackRequestContext { + func implementation(type: SensoryFeedback.FeedbackType) -> (any PlatformSensoryFeedback)? { + switch type { + case .alignment: HapticFeedbackManagerImplementation(pattern: .alignment) + case .levelChange: HapticFeedbackManagerImplementation(pattern: .levelChange) + default: nil + } + } +} +#endif diff --git a/Sources/OpenSwiftUI/View/Control/SensoryFeedback/SensoryFeedback.swift b/Sources/OpenSwiftUI/View/Control/SensoryFeedback/SensoryFeedback.swift new file mode 100644 index 000000000..7302b6a2e --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/SensoryFeedback/SensoryFeedback.swift @@ -0,0 +1,207 @@ +// +// SensoryFeedback.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +// MARK: - SensoryFeedback + +/// Represents a type of haptic and/or audio feedback that can be played. +/// +/// This feedback can be passed to `View.sensoryFeedback` to play it. +@available(OpenSwiftUI_v5_0, *) +@available(visionOS, unavailable) +public struct SensoryFeedback: Equatable, Sendable { + enum FeedbackType: Hashable { + case success + case warning + case error + case increase + case decrease + case selection + case alignment + case levelChange + case start + case stop + case pathComplete + case impactWeight(SensoryFeedback.Weight.Storage, Double) + case impactFlexibility(SensoryFeedback.Flexibility.Storage, Double) + } + + var type: FeedbackType + + /// Indicates that a task or action has completed. + /// + /// Only plays feedback on iOS and watchOS. + public static let success: SensoryFeedback = .init(type: .success) + + /// Indicates that a task or action has produced a warning of some kind. + /// + /// Only plays feedback on iOS and watchOS. + public static let warning: SensoryFeedback = .init(type: .warning) + + /// Indicates that an error has occurred. + /// + /// Only plays feedback on iOS and watchOS. + public static let error: SensoryFeedback = .init(type: .error) + + /// Indicates that a UI element’s values are changing. + /// + /// Only plays feedback on iOS and watchOS. + public static let selection: SensoryFeedback = .init(type: .selection) + + /// Indicates that an important value increased above a significant + /// threshold. + /// + /// Only plays feedback on watchOS. + public static let increase: SensoryFeedback = .init(type: .increase) + + /// Indicates that an important value decreased below a significant + /// threshold. + /// + /// Only plays feedback on watchOS. + public static let decrease: SensoryFeedback = .init(type: .decrease) + + /// Indicates that an activity started. + /// + /// Use this haptic when starting a timer or any other activity that can be + /// explicitly started and stopped. + /// + /// Only plays feedback on watchOS. + public static let start: SensoryFeedback = .init(type: .start) + + /// Indicates that an activity stopped. + /// + /// Use this haptic when stopping a timer or other activity that was + /// previously started. + /// + /// Only plays feedback on watchOS. + public static let stop: SensoryFeedback = .init(type: .stop) + + /// Indicates the alignment of a dragged item. + /// + /// For example, use this pattern in a drawing app when the user drags a + /// shape into alignment with another shape. + /// + /// Only plays feedback on iOS and macOS. + public static let alignment: SensoryFeedback = .init(type: .alignment) + + /// Indicates movement between discrete levels of pressure. + /// + /// For example, as the user presses a fast-forward button on a video + /// player, playback could increase or decrease and haptic feedback could be + /// provided as different levels of pressure are reached. + /// + /// Only plays feedback on macOS. + public static let levelChange: SensoryFeedback = .init(type: .levelChange) + + /// Indicates a drawn path has completed and/or recognized. + /// + /// Use this to provide feedback for closed shape drawing or similar + /// actions. It should supplement the user experience, since only some + /// platforms will play feedback in response to it. + /// + /// Only plays feedback on iOS. + @available(OpenSwiftUI_v5_5, *) + public static let pathComplete: SensoryFeedback = .init(type: .pathComplete) + + /// Provides a physical metaphor you can use to complement a visual + /// experience. + /// + /// Use this to provide feedback for UI elements colliding. It should + /// supplement the user experience, since only some platforms will play + /// feedback in response to it. + /// + /// Only plays feedback on iOS and watchOS. + public static let impact: SensoryFeedback = .init(type: .impactWeight(.light, 1.0)) + + /// Provides a physical metaphor you can use to complement a visual + /// experience. + /// + /// Use this to provide feedback for UI elements colliding. It should + /// supplement the user experience, since only some platforms will play + /// feedback in response to it. + /// + /// Not all platforms will play different feedback for different weights and + /// intensities of impact. + /// + /// Only plays feedback on iOS and watchOS. + public static func impact(weight: SensoryFeedback.Weight, intensity: Double = 1.0) -> SensoryFeedback { + .init(type: .impactWeight(weight.storage, intensity)) + } + + /// Provides a physical metaphor you can use to complement a visual + /// experience. + /// + /// Use this to provide feedback for UI elements colliding. It should + /// supplement the user experience, since only some platforms will play + /// feedback in response to it. + /// + /// Not all platforms will play different feedback for different + /// flexibilities and intensities of impact. + /// + /// Only plays feedback on iOS and watchOS. + public static func impact(flexibility: SensoryFeedback.Flexibility, intensity: Double = 1.0) -> SensoryFeedback { + .init(type: .impactFlexibility(flexibility.storage, intensity)) + } + + // MARK: - SensoryFeedback.Weight + + /// The weight to be represented by a type of feedback. + /// + /// `Weight` values can be passed to + /// `SensoryFeedback.impact(weight:intensity:)`. + public struct Weight: Equatable, Sendable { + enum Storage: Hashable { + case light + case medium + case heavy + } + + var storage: Storage + + /// Indicates a collision between small or lightweight UI objects. + public static let light: SensoryFeedback.Weight = .init(storage: .light) + + /// Indicates a collision between medium-sized or medium-weight UI + /// objects. + public static let medium: SensoryFeedback.Weight = .init(storage: .medium) + + /// Indicates a collision between large or heavyweight UI objects. + public static let heavy: SensoryFeedback.Weight = .init(storage: .heavy) + } + + // MARK: - SensoryFeedback.Flexibility + + /// The flexibility to be represented by a type of feedback. + /// + /// `Flexibility` values can be passed to + /// `SensoryFeedback.impact(flexibility:intensity:)`. + public struct Flexibility: Equatable, Sendable { + enum Storage: Hashable { + case rigid + case solid + case soft + } + var storage: Storage + + /// Indicates a collision between hard or inflexible UI objects. + public static let rigid: SensoryFeedback.Flexibility = .init(storage: .rigid) + + /// Indicates a collision between solid UI objects of medium + /// flexibility. + public static let solid: SensoryFeedback.Flexibility = .init(storage: .solid) + + /// Indicates a collision between soft or flexible UI objects. + public static let soft: SensoryFeedback.Flexibility = .init(storage: .soft) + } +} + +// MARK: - PlatformSensoryFeedback + +protocol PlatformSensoryFeedback { + func setUp() + func tearDown() + func generate() +} diff --git a/Sources/OpenSwiftUI/View/Control/SensoryFeedback/SensoryFeedbackGenerator.swift b/Sources/OpenSwiftUI/View/Control/SensoryFeedback/SensoryFeedbackGenerator.swift new file mode 100644 index 000000000..36ca26246 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/SensoryFeedback/SensoryFeedbackGenerator.swift @@ -0,0 +1,248 @@ +// +// SensoryFeedbackGenerator.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: E5C2FE5C277CCA85C518490456542950 (SwiftUI) + +// MARK: - View + senosryFeedback + +@available(OpenSwiftUI_v5_0, *) +@available(visionOS, unavailable) +extension View { + + /// Plays the specified `feedback` when the provided `trigger` value + /// changes. + /// + /// For example, you could play feedback when a state value changes: + /// + /// struct MyView: View { + /// @State private var showAccessory = false + /// + /// var body: some View { + /// ContentView() + /// .sensoryFeedback(.selection, trigger: showAccessory) + /// .onLongPressGesture { + /// showAccessory.toggle() + /// } + /// + /// if showAccessory { + /// AccessoryView() + /// } + /// } + /// } + /// + /// - Parameters: + /// - feedback: Which type of feedback to play. + /// - trigger: A value to monitor for changes to determine when to play. + nonisolated public func sensoryFeedback( + _ feedback: SensoryFeedback, + trigger: T + ) -> some View where T: Equatable { + platformSensoryFeedback( + FeedbackGenerator( + feedbackRequestContext: .init(), + feedback: feedback, + trigger: trigger, + condition: nil, + implementation: nil + ) + ) + } + + /// Plays the specified `feedback` when the provided `trigger` value changes + /// and the `condition` closure returns `true`. + /// + /// For example, you could play feedback for certain state transitions: + /// + /// struct MyView: View { + /// @State private var phase = Phase.inactive + /// + /// var body: some View { + /// ContentView(phase: $phase) + /// .sensoryFeedback(.selection, trigger: phase) { old, new in + /// old == .inactive || new == .expanded + /// } + /// } + /// + /// enum Phase { + /// case inactive + /// case preparing + /// case active + /// case expanded + /// } + /// } + /// + /// - Parameters: + /// - feedback: Which type of feedback to play. + /// - trigger: A value to monitor for changes to determine when to play. + /// - condition: A closure to determine whether to play the feedback when + /// `trigger` changes. + nonisolated public func sensoryFeedback( + _ feedback: SensoryFeedback, + trigger: T, + condition: @escaping (_ oldValue: T, _ newValue: T) -> Bool + ) -> some View where T: Equatable { + platformSensoryFeedback( + FeedbackGenerator( + feedbackRequestContext: .init(), + feedback: feedback, + trigger: trigger, + condition: condition, + implementation: nil + ) + ) + } + + /// Plays feedback when returned from the `feedback` closure after the + /// provided `trigger` value changes. + /// + /// For example, you could play different feedback for different state + /// transitions: + /// + /// struct MyView: View { + /// @State private var phase = Phase.inactive + /// + /// var body: some View { + /// ContentView(phase: $phase) + /// .sensoryFeedback(trigger: phase) { old, new in + /// switch (old, new) { + /// case (.inactive, _): return .success + /// case (_, .expanded): return .impact + /// default: return nil + /// } + /// } + /// } + /// + /// enum Phase { + /// case inactive + /// case preparing + /// case active + /// case expanded + /// } + /// } + /// + /// - Parameters: + /// - trigger: A value to monitor for changes to determine when to play. + /// - feedback: A closure to determine whether to play the feedback and + /// what type of feedback to play when `trigger` changes. + nonisolated public func sensoryFeedback( + trigger: T, + _ feedback: @escaping (_ oldValue: T, _ newValue: T) -> SensoryFeedback? + ) -> some View where T: Equatable { + platformSensoryFeedback( + CustomFeedbackGenerator( + feedbackRequestContext: .init(), + trigger: trigger, + feedback: feedback + ) + ) + } +} + +// MARK: - SensoryFeedbackGeneratorModifier + +protocol SensoryFeedbackGeneratorModifier: ViewModifier { + var feedbackRequestContext: FeedbackRequestContext { get set } +} + +// MARK: - CustomFeedbackGenerator + +private struct CustomFeedbackGenerator: SensoryFeedbackGeneratorModifier where T: Equatable { + var feedbackRequestContext: FeedbackRequestContext + var trigger: T + var feedback: (T, T) -> SensoryFeedback? + @State var state: (feedback: SensoryFeedback, implementation: (any PlatformSensoryFeedback)?)? + + init( + feedbackRequestContext: FeedbackRequestContext, + trigger: T, + feedback: @escaping (T, T) -> SensoryFeedback? + ) { + self.feedbackRequestContext = feedbackRequestContext + self.trigger = trigger + self.feedback = feedback + self._state = State(wrappedValue: nil) + } + + func body(content: Content) -> some View { + content + .onChange(of: trigger) { oldValue, newValue in + let newFeedback = feedback(oldValue, newValue) + if state?.feedback != newFeedback { + state?.implementation?.tearDown() + let newValue: (SensoryFeedback, (any PlatformSensoryFeedback)?)? + if let newFeedback { + newValue = ( + newFeedback, + feedbackRequestContext.implementation(type: newFeedback.type) + ) + } else { + newValue = nil + } + state = newValue + state?.implementation?.setUp() + } + state?.implementation?.generate() + } + } +} + +// MARK: - FeedbackGenerator + +private struct FeedbackGenerator: SensoryFeedbackGeneratorModifier where T: Equatable { + var feedbackRequestContext: FeedbackRequestContext + var feedback: SensoryFeedback + var trigger: T + var condition: ((T, T) -> Bool)? + @State var implementation: (any PlatformSensoryFeedback)? + + init( + feedbackRequestContext: FeedbackRequestContext, + feedback: SensoryFeedback, + trigger: T, + condition: ((T, T) -> Bool)?, + implementation: (any PlatformSensoryFeedback)? + ) { + self.feedbackRequestContext = feedbackRequestContext + self.feedback = feedback + self.trigger = trigger + self.condition = condition + self._implementation = .init(initialValue: implementation) + } + + func body(content: Content) -> some View { + content + .task(id: feedback) { + implementation?.tearDown() + implementation = feedbackRequestContext.implementation(type: feedback.type) + implementation?.setUp() + } + .onChange(of: trigger) { oldValue, newValue in + if condition?(oldValue, newValue) ?? true { + implementation?.generate() + } + } + } +} + +#if !canImport(Darwin) +// MARK: - View + platformSensoryFeedback + +extension View { + nonisolated func platformSensoryFeedback( + _ base: Base + ) -> some View where Base: SensoryFeedbackGeneratorModifier { + modifier(base) + } +} + +// MARK: - FeedbackRequestContext + +struct FeedbackRequestContext { + func implementation(type: SensoryFeedback.FeedbackType) -> (any PlatformSensoryFeedback)? { + nil + } +} +#endif diff --git a/Sources/OpenSwiftUI/View/Control/SensoryFeedback/UIKit/UIKitFeedbackImplementation.swift b/Sources/OpenSwiftUI/View/Control/SensoryFeedback/UIKit/UIKitFeedbackImplementation.swift new file mode 100644 index 000000000..aecbb009e --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/SensoryFeedback/UIKit/UIKitFeedbackImplementation.swift @@ -0,0 +1,246 @@ +// +// UIKitFeedbackImplementation.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: C9541C03AF81FECFD19A57A1BB81CE81 (SwiftUI) + +#if os(iOS) || os(visionOS) + +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore +import UIKit + +// MARK: - LocationBasedSensoryFeedback + +protocol LocationBasedSensoryFeedback: PlatformSensoryFeedback { + func generate(location: CGPoint) +} + +// MARK: - View + platformSensoryFeedback + +extension View { + nonisolated func platformSensoryFeedback( + _ base: Base + ) -> some View where Base: SensoryFeedbackGeneratorModifier { + modifier(FeedbackRequestContextWriter(base: base)) + } +} + +// MARK: - FeedbackRequestContextWriter + +private struct FeedbackRequestContextWriter: MultiViewModifier, PrimitiveViewModifier where Base: SensoryFeedbackGeneratorModifier { + var base: Base + + nonisolated static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + let base = modifier[offset: { .of(&$0.base) }].value + let child = { + guard inputs.needsGeometry else { + return base + } + let location = if inputs.base.interfaceIdiom.accepts(.pad) { + Attribute( + FeedbackLocation( + position: inputs.position, + size: inputs.size, + transform: inputs.transform + ) + ) + } else { + ViewGraph.current.$zeroPoint + } + let feedbackCache = inputs.base.feedbackCache + let modifier = Attribute( + ChildModifier( + base: base, + location: location, + feedbackCache: feedbackCache + ) + ) + return modifier + }() + return Base._makeView( + modifier: _GraphValue(child), + inputs: inputs, + body: body + ) + } + + struct ChildModifier: Rule { + @Attribute var base: Base + @Attribute var location: CGPoint + @Attribute var feedbackCache: AnyUIKitSensoryFeedbackCache? + + var value: Base { + var base = base + base.feedbackRequestContext = .init(location: WeakAttribute($location), cache: feedbackCache) + return base + } + } +} + +// MARK: - NotificationFeedbackImplementation + +struct NotificationFeedbackImplementation: LocationBasedSensoryFeedback { + let generator: UINotificationFeedbackGenerator + + let type: UINotificationFeedbackGenerator.FeedbackType + + func setUp() { + generator.prepare() + } + + func tearDown() { + _openSwiftUIEmptyStub() + } + + func generate() { + preconditionFailure("Requires location.") + } + + func generate(location: CGPoint) { + generator.notificationOccurred(type, at: location) + } +} + +// MARK: - SelectionFeedbackImplementation + +struct SelectionFeedbackImplementation: LocationBasedSensoryFeedback { + let generator: UISelectionFeedbackGenerator + + func setUp() { + generator.prepare() + } + + func tearDown() { + _openSwiftUIEmptyStub() + } + + func generate() { + preconditionFailure("Requires location.") + } + + func generate(location: CGPoint) { + generator.selectionChanged(at: location) + } +} + +// MARK: - CanvasFeedbackImplementation + +struct CanvasFeedbackImplementation: LocationBasedSensoryFeedback { + let generator: UICanvasFeedbackGenerator + let type: SensoryFeedback.FeedbackType + + func setUp() { + generator.prepare() + } + + func tearDown() { + _openSwiftUIEmptyStub() + } + + func generate() { + preconditionFailure("Requires location.") + } + + func generate(location: CGPoint) { + switch type { + case .alignment: + generator.alignmentOccurred(at: location) + case .pathComplete: + generator.pathCompleted(at: location) + default: + break + } + } +} + +// MARK: - ImpactFeedbackImplementation + +struct ImpactFeedbackImplementation: LocationBasedSensoryFeedback { + let generator: UIImpactFeedbackGenerator + var intensity: Double + + func setUp() { + generator.prepare() + } + + func tearDown() { + _openSwiftUIEmptyStub() + } + + func generate() { + preconditionFailure("Requires location.") + } + + func generate(location: CGPoint) { + generator.impactOccurred(intensity: intensity, at: location) + } +} + +// MARK: - LocationBasedFeedbackAdaptor + +struct LocationBasedFeedbackAdaptor: PlatformSensoryFeedback { + var location: Attribute + var base: any LocationBasedSensoryFeedback + + func setUp() { + base.setUp() + } + + func tearDown() { + base.tearDown() + } + + func generate() { + let currentLocation: CGPoint = Update.ensure { + Graph.withoutUpdate { + location.value + } + } + base.generate(location: currentLocation) + } +} + +// MARK: - LocationBasedFeedbackAdaptor + +private struct FeedbackLocation: Rule { + @Attribute var position: ViewOrigin + @Attribute var size: ViewSize + @Attribute var transform: ViewTransform + + var value: CGPoint { + var transform = transform + transform.appendPosition(position) + var rect = CGRect(origin: position, size: size.value) + rect.convert(to: .id(hostingViewCoordinateSpace), transform: transform) + return rect.center + } +} + +// MARK: - FeedbackRequestContext + +struct FeedbackRequestContext { + var location: WeakAttribute = .init() + weak var cache: AnyUIKitSensoryFeedbackCache? + + func implementation(type: SensoryFeedback.FeedbackType) -> (any PlatformSensoryFeedback)? { + guard let cache, + let feeback = cache.implementation(type: type), + let location = location.attribute else { + return nil + } + return LocationBasedFeedbackAdaptor( + location: location, + base: feeback + ) + } +} + +#endif diff --git a/Sources/OpenSwiftUI/View/Control/SensoryFeedback/UIKit/UIKitSensoryFeedbackCache.swift b/Sources/OpenSwiftUI/View/Control/SensoryFeedback/UIKit/UIKitSensoryFeedbackCache.swift new file mode 100644 index 000000000..a2730e19f --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/SensoryFeedback/UIKit/UIKitSensoryFeedbackCache.swift @@ -0,0 +1,151 @@ +// +// UIKitSensoryFeedbackCache.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 54085EE6CA77C60CA86318C59A381498 (SwiftUI) + +#if os(iOS) || os(visionOS) + +import OpenAttributeGraphShims +import OpenSwiftUICore +import UIKit + +// MARK: - AnyUIKitSensoryFeedbackCache + +class AnyUIKitSensoryFeedbackCache { + func implementation( + type: SensoryFeedback.FeedbackType + ) -> LocationBasedSensoryFeedback? { + _openSwiftUIBaseClassAbstractMethod() + } +} + +// MARK: - UIKitSensoryFeedbackCache + +class UIKitSensoryFeedbackCache: AnyUIKitSensoryFeedbackCache where V: View { + weak var host: _UIHostingView? + var cachedGenerators: [SensoryFeedback.FeedbackType: UIFeedbackGenerator] = [:] + + override func implementation( + type: SensoryFeedback.FeedbackType + ) -> LocationBasedSensoryFeedback? { + switch type { + case .success: + getGenerator(type) { + NotificationFeedbackImplementation( + generator: $0, + type: .success + ) + } createIfNeeded: { + UINotificationFeedbackGenerator() + } + case .warning: + getGenerator(type) { + NotificationFeedbackImplementation( + generator: $0, + type: .warning + ) + } createIfNeeded: { + UINotificationFeedbackGenerator() + } + case .error: + getGenerator(type) { + NotificationFeedbackImplementation( + generator: $0, + type: .error + ) + } createIfNeeded: { + UINotificationFeedbackGenerator() + } + // SwiftUI implementation Bug: introduced since iOS 17 & iOS 26.2 is still not fixed + // FB21332474 + case /*.increase, .decrease,*/ .selection: + getGenerator(type) { + SelectionFeedbackImplementation( + generator: $0 + ) + } createIfNeeded: { + UISelectionFeedbackGenerator() + } + case .alignment, .pathComplete: + getGenerator(type) { + CanvasFeedbackImplementation( + generator: $0, + type: type + ) + } createIfNeeded: { + UICanvasFeedbackGenerator() + } + case let .impactWeight(weight, intensity): + getGenerator(type) { + ImpactFeedbackImplementation( + generator: $0, + intensity: intensity + ) + } createIfNeeded: { () -> UIImpactFeedbackGenerator in + switch weight { + case .light: UIImpactFeedbackGenerator(style: .light) + case .medium: UIImpactFeedbackGenerator(style: .medium) + case .heavy: UIImpactFeedbackGenerator(style: .heavy) + } + } + case let .impactFlexibility(flexibility, intensity): + getGenerator(type) { + ImpactFeedbackImplementation( + generator: $0, + intensity: intensity + ) + } createIfNeeded: { () -> UIImpactFeedbackGenerator in + switch flexibility { + case .rigid: UIImpactFeedbackGenerator(style: .rigid) + case .solid: UIImpactFeedbackGenerator(style: .medium) + case .soft: UIImpactFeedbackGenerator(style: .soft) + } + } + default: nil + } + } + + private func getGenerator( + _ type: SensoryFeedback.FeedbackType, + work: (Generator) -> Feedback, + createIfNeeded: () -> Generator + ) -> Feedback where Generator: UIFeedbackGenerator, Feedback: LocationBasedSensoryFeedback { + let generator: Generator + if let cachedGenerator = cachedGenerators[type] { + generator = cachedGenerator as! Generator + } else { + generator = createIfNeeded() + cachedGenerators[type] = generator + host!.addInteraction(generator) + } + return work(generator) + } +} + +// MARK: - FeedbackCacheKey + +private struct FeedbackCacheKey: EnvironmentKey { + static var defaultValue: WeakBox { .init() } +} + +extension CachedEnvironment.ID { + static let feedbackCache: CachedEnvironment.ID = .init() +} + +extension _GraphInputs { + var feedbackCache: Attribute { + mapEnvironment(id: .feedbackCache) { $0.feedbackCache } + } +} + +extension EnvironmentValues { + var feedbackCache: AnyUIKitSensoryFeedbackCache? { + get { self[FeedbackCacheKey.self].base } + set { self[FeedbackCacheKey.self] = WeakBox(newValue) } + } +} + +#endif