├── .gitignore ├── Example ├── Example.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved ├── Example │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── ContentView.swift │ ├── Example.entitlements │ └── ExampleApp.swift └── ExampleUITests │ ├── AppTestCase.swift │ ├── IOSTests.swift │ └── MacOSTests.swift ├── LICENSE ├── Package.swift ├── README.md └── Sources └── SwiftUIUndo ├── Internal ├── DeviceShakeViewModifier.swift ├── UndoHandler.swift ├── UndoManager+Util.swift ├── UndoRedoAwareModifier.swift └── UndoRedoShakeAlertModifier.swift └── View+Undo.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 00AE49B62DDF563B00CE4680 /* SwiftUIUndo in Frameworks */ = {isa = PBXBuildFile; productRef = 00AE49B52DDF563B00CE4680 /* SwiftUIUndo */; }; 11 | 00AE49C82DDF585B00CE4680 /* XCAppTest in Frameworks */ = {isa = PBXBuildFile; productRef = 00AE49C72DDF585B00CE4680 /* XCAppTest */; }; 12 | /* End PBXBuildFile section */ 13 | 14 | /* Begin PBXContainerItemProxy section */ 15 | 00AE49A02DDF560200CE4680 /* PBXContainerItemProxy */ = { 16 | isa = PBXContainerItemProxy; 17 | containerPortal = 00AE497F2DDF55FF00CE4680 /* Project object */; 18 | proxyType = 1; 19 | remoteGlobalIDString = 00AE49862DDF55FF00CE4680; 20 | remoteInfo = Example; 21 | }; 22 | /* End PBXContainerItemProxy section */ 23 | 24 | /* Begin PBXFileReference section */ 25 | 00AE49872DDF55FF00CE4680 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 26 | 00AE499F2DDF560200CE4680 /* ExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 27 | /* End PBXFileReference section */ 28 | 29 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 30 | 00AE49892DDF55FF00CE4680 /* Example */ = { 31 | isa = PBXFileSystemSynchronizedRootGroup; 32 | path = Example; 33 | sourceTree = ""; 34 | }; 35 | 00AE49A22DDF560200CE4680 /* ExampleUITests */ = { 36 | isa = PBXFileSystemSynchronizedRootGroup; 37 | path = ExampleUITests; 38 | sourceTree = ""; 39 | }; 40 | /* End PBXFileSystemSynchronizedRootGroup section */ 41 | 42 | /* Begin PBXFrameworksBuildPhase section */ 43 | 00AE49842DDF55FF00CE4680 /* Frameworks */ = { 44 | isa = PBXFrameworksBuildPhase; 45 | buildActionMask = 2147483647; 46 | files = ( 47 | 00AE49B62DDF563B00CE4680 /* SwiftUIUndo in Frameworks */, 48 | ); 49 | runOnlyForDeploymentPostprocessing = 0; 50 | }; 51 | 00AE499C2DDF560200CE4680 /* Frameworks */ = { 52 | isa = PBXFrameworksBuildPhase; 53 | buildActionMask = 2147483647; 54 | files = ( 55 | 00AE49C82DDF585B00CE4680 /* XCAppTest in Frameworks */, 56 | ); 57 | runOnlyForDeploymentPostprocessing = 0; 58 | }; 59 | /* End PBXFrameworksBuildPhase section */ 60 | 61 | /* Begin PBXGroup section */ 62 | 00AE497E2DDF55FF00CE4680 = { 63 | isa = PBXGroup; 64 | children = ( 65 | 00AE49892DDF55FF00CE4680 /* Example */, 66 | 00AE49A22DDF560200CE4680 /* ExampleUITests */, 67 | 00AE49882DDF55FF00CE4680 /* Products */, 68 | ); 69 | sourceTree = ""; 70 | }; 71 | 00AE49882DDF55FF00CE4680 /* Products */ = { 72 | isa = PBXGroup; 73 | children = ( 74 | 00AE49872DDF55FF00CE4680 /* Example.app */, 75 | 00AE499F2DDF560200CE4680 /* ExampleUITests.xctest */, 76 | ); 77 | name = Products; 78 | sourceTree = ""; 79 | }; 80 | /* End PBXGroup section */ 81 | 82 | /* Begin PBXNativeTarget section */ 83 | 00AE49862DDF55FF00CE4680 /* Example */ = { 84 | isa = PBXNativeTarget; 85 | buildConfigurationList = 00AE49A92DDF560200CE4680 /* Build configuration list for PBXNativeTarget "Example" */; 86 | buildPhases = ( 87 | 00AE49832DDF55FF00CE4680 /* Sources */, 88 | 00AE49842DDF55FF00CE4680 /* Frameworks */, 89 | 00AE49852DDF55FF00CE4680 /* Resources */, 90 | ); 91 | buildRules = ( 92 | ); 93 | dependencies = ( 94 | ); 95 | fileSystemSynchronizedGroups = ( 96 | 00AE49892DDF55FF00CE4680 /* Example */, 97 | ); 98 | name = Example; 99 | packageProductDependencies = ( 100 | 00AE49B52DDF563B00CE4680 /* SwiftUIUndo */, 101 | ); 102 | productName = Example; 103 | productReference = 00AE49872DDF55FF00CE4680 /* Example.app */; 104 | productType = "com.apple.product-type.application"; 105 | }; 106 | 00AE499E2DDF560200CE4680 /* ExampleUITests */ = { 107 | isa = PBXNativeTarget; 108 | buildConfigurationList = 00AE49AF2DDF560200CE4680 /* Build configuration list for PBXNativeTarget "ExampleUITests" */; 109 | buildPhases = ( 110 | 00AE499B2DDF560200CE4680 /* Sources */, 111 | 00AE499C2DDF560200CE4680 /* Frameworks */, 112 | 00AE499D2DDF560200CE4680 /* Resources */, 113 | ); 114 | buildRules = ( 115 | ); 116 | dependencies = ( 117 | 00AE49A12DDF560200CE4680 /* PBXTargetDependency */, 118 | ); 119 | fileSystemSynchronizedGroups = ( 120 | 00AE49A22DDF560200CE4680 /* ExampleUITests */, 121 | ); 122 | name = ExampleUITests; 123 | packageProductDependencies = ( 124 | 00AE49C72DDF585B00CE4680 /* XCAppTest */, 125 | ); 126 | productName = ExampleUITests; 127 | productReference = 00AE499F2DDF560200CE4680 /* ExampleUITests.xctest */; 128 | productType = "com.apple.product-type.bundle.ui-testing"; 129 | }; 130 | /* End PBXNativeTarget section */ 131 | 132 | /* Begin PBXProject section */ 133 | 00AE497F2DDF55FF00CE4680 /* Project object */ = { 134 | isa = PBXProject; 135 | attributes = { 136 | BuildIndependentTargetsInParallel = 1; 137 | LastSwiftUpdateCheck = 1630; 138 | LastUpgradeCheck = 1630; 139 | TargetAttributes = { 140 | 00AE49862DDF55FF00CE4680 = { 141 | CreatedOnToolsVersion = 16.3; 142 | }; 143 | 00AE499E2DDF560200CE4680 = { 144 | CreatedOnToolsVersion = 16.3; 145 | TestTargetID = 00AE49862DDF55FF00CE4680; 146 | }; 147 | }; 148 | }; 149 | buildConfigurationList = 00AE49822DDF55FF00CE4680 /* Build configuration list for PBXProject "Example" */; 150 | developmentRegion = en; 151 | hasScannedForEncodings = 0; 152 | knownRegions = ( 153 | en, 154 | Base, 155 | ); 156 | mainGroup = 00AE497E2DDF55FF00CE4680; 157 | minimizedProjectReferenceProxies = 1; 158 | packageReferences = ( 159 | 00AE49B42DDF563B00CE4680 /* XCLocalSwiftPackageReference "../../SwiftUIUndo" */, 160 | 00AE49C62DDF585B00CE4680 /* XCRemoteSwiftPackageReference "XCAppTest" */, 161 | ); 162 | preferredProjectObjectVersion = 77; 163 | productRefGroup = 00AE49882DDF55FF00CE4680 /* Products */; 164 | projectDirPath = ""; 165 | projectRoot = ""; 166 | targets = ( 167 | 00AE49862DDF55FF00CE4680 /* Example */, 168 | 00AE499E2DDF560200CE4680 /* ExampleUITests */, 169 | ); 170 | }; 171 | /* End PBXProject section */ 172 | 173 | /* Begin PBXResourcesBuildPhase section */ 174 | 00AE49852DDF55FF00CE4680 /* Resources */ = { 175 | isa = PBXResourcesBuildPhase; 176 | buildActionMask = 2147483647; 177 | files = ( 178 | ); 179 | runOnlyForDeploymentPostprocessing = 0; 180 | }; 181 | 00AE499D2DDF560200CE4680 /* Resources */ = { 182 | isa = PBXResourcesBuildPhase; 183 | buildActionMask = 2147483647; 184 | files = ( 185 | ); 186 | runOnlyForDeploymentPostprocessing = 0; 187 | }; 188 | /* End PBXResourcesBuildPhase section */ 189 | 190 | /* Begin PBXSourcesBuildPhase section */ 191 | 00AE49832DDF55FF00CE4680 /* Sources */ = { 192 | isa = PBXSourcesBuildPhase; 193 | buildActionMask = 2147483647; 194 | files = ( 195 | ); 196 | runOnlyForDeploymentPostprocessing = 0; 197 | }; 198 | 00AE499B2DDF560200CE4680 /* Sources */ = { 199 | isa = PBXSourcesBuildPhase; 200 | buildActionMask = 2147483647; 201 | files = ( 202 | ); 203 | runOnlyForDeploymentPostprocessing = 0; 204 | }; 205 | /* End PBXSourcesBuildPhase section */ 206 | 207 | /* Begin PBXTargetDependency section */ 208 | 00AE49A12DDF560200CE4680 /* PBXTargetDependency */ = { 209 | isa = PBXTargetDependency; 210 | target = 00AE49862DDF55FF00CE4680 /* Example */; 211 | targetProxy = 00AE49A02DDF560200CE4680 /* PBXContainerItemProxy */; 212 | }; 213 | /* End PBXTargetDependency section */ 214 | 215 | /* Begin XCBuildConfiguration section */ 216 | 00AE49A72DDF560200CE4680 /* Debug */ = { 217 | isa = XCBuildConfiguration; 218 | buildSettings = { 219 | ALWAYS_SEARCH_USER_PATHS = NO; 220 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 221 | CLANG_ANALYZER_NONNULL = YES; 222 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 223 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 224 | CLANG_ENABLE_MODULES = YES; 225 | CLANG_ENABLE_OBJC_ARC = YES; 226 | CLANG_ENABLE_OBJC_WEAK = YES; 227 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 228 | CLANG_WARN_BOOL_CONVERSION = YES; 229 | CLANG_WARN_COMMA = YES; 230 | CLANG_WARN_CONSTANT_CONVERSION = YES; 231 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 232 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 233 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 234 | CLANG_WARN_EMPTY_BODY = YES; 235 | CLANG_WARN_ENUM_CONVERSION = YES; 236 | CLANG_WARN_INFINITE_RECURSION = YES; 237 | CLANG_WARN_INT_CONVERSION = YES; 238 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 239 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 240 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 241 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 242 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 243 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 244 | CLANG_WARN_STRICT_PROTOTYPES = YES; 245 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 246 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 247 | CLANG_WARN_UNREACHABLE_CODE = YES; 248 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 249 | COPY_PHASE_STRIP = NO; 250 | DEBUG_INFORMATION_FORMAT = dwarf; 251 | ENABLE_STRICT_OBJC_MSGSEND = YES; 252 | ENABLE_TESTABILITY = YES; 253 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 254 | GCC_C_LANGUAGE_STANDARD = gnu17; 255 | GCC_DYNAMIC_NO_PIC = NO; 256 | GCC_NO_COMMON_BLOCKS = YES; 257 | GCC_OPTIMIZATION_LEVEL = 0; 258 | GCC_PREPROCESSOR_DEFINITIONS = ( 259 | "DEBUG=1", 260 | "$(inherited)", 261 | ); 262 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 263 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 264 | GCC_WARN_UNDECLARED_SELECTOR = YES; 265 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 266 | GCC_WARN_UNUSED_FUNCTION = YES; 267 | GCC_WARN_UNUSED_VARIABLE = YES; 268 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 269 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 270 | MTL_FAST_MATH = YES; 271 | ONLY_ACTIVE_ARCH = YES; 272 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 273 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 274 | }; 275 | name = Debug; 276 | }; 277 | 00AE49A82DDF560200CE4680 /* Release */ = { 278 | isa = XCBuildConfiguration; 279 | buildSettings = { 280 | ALWAYS_SEARCH_USER_PATHS = NO; 281 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 282 | CLANG_ANALYZER_NONNULL = YES; 283 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 284 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 285 | CLANG_ENABLE_MODULES = YES; 286 | CLANG_ENABLE_OBJC_ARC = YES; 287 | CLANG_ENABLE_OBJC_WEAK = YES; 288 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 289 | CLANG_WARN_BOOL_CONVERSION = YES; 290 | CLANG_WARN_COMMA = YES; 291 | CLANG_WARN_CONSTANT_CONVERSION = YES; 292 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 293 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 294 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 295 | CLANG_WARN_EMPTY_BODY = YES; 296 | CLANG_WARN_ENUM_CONVERSION = YES; 297 | CLANG_WARN_INFINITE_RECURSION = YES; 298 | CLANG_WARN_INT_CONVERSION = YES; 299 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 300 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 301 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 302 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 303 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 304 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 305 | CLANG_WARN_STRICT_PROTOTYPES = YES; 306 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 307 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 308 | CLANG_WARN_UNREACHABLE_CODE = YES; 309 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 310 | COPY_PHASE_STRIP = NO; 311 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 312 | ENABLE_NS_ASSERTIONS = NO; 313 | ENABLE_STRICT_OBJC_MSGSEND = YES; 314 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 315 | GCC_C_LANGUAGE_STANDARD = gnu17; 316 | GCC_NO_COMMON_BLOCKS = YES; 317 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 318 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 319 | GCC_WARN_UNDECLARED_SELECTOR = YES; 320 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 321 | GCC_WARN_UNUSED_FUNCTION = YES; 322 | GCC_WARN_UNUSED_VARIABLE = YES; 323 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 324 | MTL_ENABLE_DEBUG_INFO = NO; 325 | MTL_FAST_MATH = YES; 326 | SWIFT_COMPILATION_MODE = wholemodule; 327 | }; 328 | name = Release; 329 | }; 330 | 00AE49AA2DDF560200CE4680 /* Debug */ = { 331 | isa = XCBuildConfiguration; 332 | buildSettings = { 333 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 334 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 335 | CODE_SIGN_ENTITLEMENTS = Example/Example.entitlements; 336 | CODE_SIGN_STYLE = Automatic; 337 | CURRENT_PROJECT_VERSION = 1; 338 | ENABLE_PREVIEWS = YES; 339 | GENERATE_INFOPLIST_FILE = YES; 340 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 341 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 342 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 343 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 344 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 345 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 346 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 347 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 348 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 349 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 350 | IPHONEOS_DEPLOYMENT_TARGET = 18.4; 351 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 352 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 353 | MACOSX_DEPLOYMENT_TARGET = 15.4; 354 | MARKETING_VERSION = 1.0; 355 | PRODUCT_BUNDLE_IDENTIFIER = swiftuiundo.Example; 356 | PRODUCT_NAME = "$(TARGET_NAME)"; 357 | REGISTER_APP_GROUPS = YES; 358 | SDKROOT = auto; 359 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; 360 | SWIFT_EMIT_LOC_STRINGS = YES; 361 | SWIFT_VERSION = 5.0; 362 | TARGETED_DEVICE_FAMILY = "1,2,7"; 363 | XROS_DEPLOYMENT_TARGET = 2.4; 364 | }; 365 | name = Debug; 366 | }; 367 | 00AE49AB2DDF560200CE4680 /* Release */ = { 368 | isa = XCBuildConfiguration; 369 | buildSettings = { 370 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 371 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 372 | CODE_SIGN_ENTITLEMENTS = Example/Example.entitlements; 373 | CODE_SIGN_STYLE = Automatic; 374 | CURRENT_PROJECT_VERSION = 1; 375 | ENABLE_PREVIEWS = YES; 376 | GENERATE_INFOPLIST_FILE = YES; 377 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 378 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 379 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 380 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 381 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 382 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 383 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 384 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 385 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 386 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 387 | IPHONEOS_DEPLOYMENT_TARGET = 18.4; 388 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 389 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 390 | MACOSX_DEPLOYMENT_TARGET = 15.4; 391 | MARKETING_VERSION = 1.0; 392 | PRODUCT_BUNDLE_IDENTIFIER = swiftuiundo.Example; 393 | PRODUCT_NAME = "$(TARGET_NAME)"; 394 | REGISTER_APP_GROUPS = YES; 395 | SDKROOT = auto; 396 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; 397 | SWIFT_EMIT_LOC_STRINGS = YES; 398 | SWIFT_VERSION = 5.0; 399 | TARGETED_DEVICE_FAMILY = "1,2,7"; 400 | XROS_DEPLOYMENT_TARGET = 2.4; 401 | }; 402 | name = Release; 403 | }; 404 | 00AE49B02DDF560200CE4680 /* Debug */ = { 405 | isa = XCBuildConfiguration; 406 | buildSettings = { 407 | CODE_SIGN_STYLE = Automatic; 408 | CURRENT_PROJECT_VERSION = 1; 409 | GENERATE_INFOPLIST_FILE = YES; 410 | IPHONEOS_DEPLOYMENT_TARGET = 18.4; 411 | MACOSX_DEPLOYMENT_TARGET = 15.4; 412 | MARKETING_VERSION = 1.0; 413 | PRODUCT_BUNDLE_IDENTIFIER = swiftuiundo.ExampleUITests; 414 | PRODUCT_NAME = "$(TARGET_NAME)"; 415 | SDKROOT = auto; 416 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; 417 | SWIFT_EMIT_LOC_STRINGS = NO; 418 | SWIFT_VERSION = 5.0; 419 | TARGETED_DEVICE_FAMILY = "1,2,7"; 420 | TEST_TARGET_NAME = Example; 421 | XROS_DEPLOYMENT_TARGET = 2.4; 422 | }; 423 | name = Debug; 424 | }; 425 | 00AE49B12DDF560200CE4680 /* Release */ = { 426 | isa = XCBuildConfiguration; 427 | buildSettings = { 428 | CODE_SIGN_STYLE = Automatic; 429 | CURRENT_PROJECT_VERSION = 1; 430 | GENERATE_INFOPLIST_FILE = YES; 431 | IPHONEOS_DEPLOYMENT_TARGET = 18.4; 432 | MACOSX_DEPLOYMENT_TARGET = 15.4; 433 | MARKETING_VERSION = 1.0; 434 | PRODUCT_BUNDLE_IDENTIFIER = swiftuiundo.ExampleUITests; 435 | PRODUCT_NAME = "$(TARGET_NAME)"; 436 | SDKROOT = auto; 437 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; 438 | SWIFT_EMIT_LOC_STRINGS = NO; 439 | SWIFT_VERSION = 5.0; 440 | TARGETED_DEVICE_FAMILY = "1,2,7"; 441 | TEST_TARGET_NAME = Example; 442 | XROS_DEPLOYMENT_TARGET = 2.4; 443 | }; 444 | name = Release; 445 | }; 446 | /* End XCBuildConfiguration section */ 447 | 448 | /* Begin XCConfigurationList section */ 449 | 00AE49822DDF55FF00CE4680 /* Build configuration list for PBXProject "Example" */ = { 450 | isa = XCConfigurationList; 451 | buildConfigurations = ( 452 | 00AE49A72DDF560200CE4680 /* Debug */, 453 | 00AE49A82DDF560200CE4680 /* Release */, 454 | ); 455 | defaultConfigurationIsVisible = 0; 456 | defaultConfigurationName = Release; 457 | }; 458 | 00AE49A92DDF560200CE4680 /* Build configuration list for PBXNativeTarget "Example" */ = { 459 | isa = XCConfigurationList; 460 | buildConfigurations = ( 461 | 00AE49AA2DDF560200CE4680 /* Debug */, 462 | 00AE49AB2DDF560200CE4680 /* Release */, 463 | ); 464 | defaultConfigurationIsVisible = 0; 465 | defaultConfigurationName = Release; 466 | }; 467 | 00AE49AF2DDF560200CE4680 /* Build configuration list for PBXNativeTarget "ExampleUITests" */ = { 468 | isa = XCConfigurationList; 469 | buildConfigurations = ( 470 | 00AE49B02DDF560200CE4680 /* Debug */, 471 | 00AE49B12DDF560200CE4680 /* Release */, 472 | ); 473 | defaultConfigurationIsVisible = 0; 474 | defaultConfigurationName = Release; 475 | }; 476 | /* End XCConfigurationList section */ 477 | 478 | /* Begin XCLocalSwiftPackageReference section */ 479 | 00AE49B42DDF563B00CE4680 /* XCLocalSwiftPackageReference "../../SwiftUIUndo" */ = { 480 | isa = XCLocalSwiftPackageReference; 481 | relativePath = ../../SwiftUIUndo; 482 | }; 483 | /* End XCLocalSwiftPackageReference section */ 484 | 485 | /* Begin XCRemoteSwiftPackageReference section */ 486 | 00AE49C62DDF585B00CE4680 /* XCRemoteSwiftPackageReference "XCAppTest" */ = { 487 | isa = XCRemoteSwiftPackageReference; 488 | repositoryURL = "https://github.com/Tunous/XCAppTest.git"; 489 | requirement = { 490 | kind = upToNextMajorVersion; 491 | minimumVersion = 1.0.0; 492 | }; 493 | }; 494 | /* End XCRemoteSwiftPackageReference section */ 495 | 496 | /* Begin XCSwiftPackageProductDependency section */ 497 | 00AE49B52DDF563B00CE4680 /* SwiftUIUndo */ = { 498 | isa = XCSwiftPackageProductDependency; 499 | productName = SwiftUIUndo; 500 | }; 501 | 00AE49C72DDF585B00CE4680 /* XCAppTest */ = { 502 | isa = XCSwiftPackageProductDependency; 503 | package = 00AE49C62DDF585B00CE4680 /* XCRemoteSwiftPackageReference "XCAppTest" */; 504 | productName = XCAppTest; 505 | }; 506 | /* End XCSwiftPackageProductDependency section */ 507 | }; 508 | rootObject = 00AE497F2DDF55FF00CE4680 /* Project object */; 509 | } 510 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "266e0f5697509c018960a394603d6f08025116019ab634c22bff9923d57903a4", 3 | "pins" : [ 4 | { 5 | "identity" : "xcapptest", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/Tunous/XCAppTest.git", 8 | "state" : { 9 | "revision" : "b663b658544dd39333eb91f4191eff92d388da63", 10 | "version" : "1.0.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | }, 30 | { 31 | "idiom" : "mac", 32 | "scale" : "1x", 33 | "size" : "16x16" 34 | }, 35 | { 36 | "idiom" : "mac", 37 | "scale" : "2x", 38 | "size" : "16x16" 39 | }, 40 | { 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "32x32" 44 | }, 45 | { 46 | "idiom" : "mac", 47 | "scale" : "2x", 48 | "size" : "32x32" 49 | }, 50 | { 51 | "idiom" : "mac", 52 | "scale" : "1x", 53 | "size" : "128x128" 54 | }, 55 | { 56 | "idiom" : "mac", 57 | "scale" : "2x", 58 | "size" : "128x128" 59 | }, 60 | { 61 | "idiom" : "mac", 62 | "scale" : "1x", 63 | "size" : "256x256" 64 | }, 65 | { 66 | "idiom" : "mac", 67 | "scale" : "2x", 68 | "size" : "256x256" 69 | }, 70 | { 71 | "idiom" : "mac", 72 | "scale" : "1x", 73 | "size" : "512x512" 74 | }, 75 | { 76 | "idiom" : "mac", 77 | "scale" : "2x", 78 | "size" : "512x512" 79 | } 80 | ], 81 | "info" : { 82 | "author" : "xcode", 83 | "version" : 1 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Example/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // SwiftUIUndo 4 | // 5 | // Created by Łukasz Rutkowski on 22/05/2025. 6 | // 7 | 8 | import SwiftUI 9 | @_spi(Shake) import SwiftUIUndo 10 | 11 | struct ContentView: View { 12 | var body: some View { 13 | NavigationStack { 14 | List { 15 | NavigationLink("Simple") { 16 | SimpleExample() 17 | } 18 | NavigationLink("Advanced") { 19 | AdvancedExample() 20 | } 21 | } 22 | } 23 | .toolbar { 24 | #if canImport(UIKit) 25 | ToolbarItem(placement: .bottomBar) { 26 | ShakeButton() 27 | } 28 | #endif 29 | } 30 | } 31 | } 32 | 33 | private struct SimpleExample: View { 34 | @State private var int = 0 35 | @State private var bool = false 36 | 37 | var body: some View { 38 | VStack { 39 | Stepper(int.formatted(), value: $int) 40 | .withUndoRedo("Change Int", for: $int) 41 | Toggle("Toggle", isOn: $bool) 42 | .withUndoRedo("Change Bool", for: $bool) 43 | } 44 | } 45 | } 46 | 47 | private struct AdvancedExample: View { 48 | struct Values: Equatable { 49 | var int: Int 50 | var bool: Bool 51 | } 52 | 53 | @State private var values = Values(int: 0, bool: false) 54 | 55 | var body: some View { 56 | VStack { 57 | Stepper(values.int.formatted(), value: $values.int) 58 | Toggle("Toggle", isOn: $values.bool) 59 | } 60 | .withUndoRedo(for: $values) { oldValues, newValues in 61 | if oldValues.int < newValues.int { 62 | return "Increment" 63 | } 64 | if oldValues.int > newValues.int { 65 | return "Decrement" 66 | } 67 | return "Toggle" 68 | } 69 | } 70 | } 71 | 72 | private struct ShakeButton: View { 73 | var body: some View { 74 | #if canImport(UIKit) 75 | Button { 76 | UIDevice.notifyAboutShake() 77 | } label: { 78 | Text(verbatim: "Shake") 79 | } 80 | #endif 81 | } 82 | } 83 | 84 | #Preview { 85 | ContentView() 86 | } 87 | -------------------------------------------------------------------------------- /Example/Example/Example.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/Example/ExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleApp.swift 3 | // Example 4 | // 5 | // Created by Łukasz Rutkowski on 22/05/2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct ExampleApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Example/ExampleUITests/AppTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppTestCase.swift 3 | // IOSTests 4 | // 5 | // Created by Łukasz Rutkowski on 23/05/2025. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | 11 | @MainActor 12 | class AppTestCase: XCTestCase { 13 | 14 | private(set) var app: XCUIApplication! 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | app = XCUIApplication() 19 | app.launch() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Example/ExampleUITests/IOSTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IOSTests.swift 3 | // IOSTests 4 | // 5 | // Created by Łukasz Rutkowski on 22/05/2025. 6 | // 7 | 8 | #if os(iOS) 9 | import XCTest 10 | import XCAppTest 11 | 12 | final class IOSTests: AppTestCase { 13 | 14 | func testShakeWithNothingToUndo() throws { 15 | app.buttons["Simple"].tap() 16 | 17 | run("Verify initial state") { 18 | stepper.assertHasValue("0") 19 | toggle.assertIsOff() 20 | alert.assertNotExists() 21 | } 22 | 23 | shakeButton.tap() 24 | 25 | alert.assertNotExists("Nothing to undo") 26 | } 27 | 28 | func testUndoAndRedo() throws { 29 | app.buttons["Simple"].tap() 30 | 31 | run("Perform changes") { 32 | stepper.buttons["Increment"].tap() 33 | toggle.switches.firstMatch.tap() 34 | stepper.assertHasValue("1") 35 | toggle.assertIsOn() 36 | } 37 | 38 | run("Undo first time") { 39 | shakeButton.tap() 40 | alert.assertHasLabel("Undo Change Bool") 41 | alert.buttons.assertMatchedElements { element in 42 | element().assertHasLabel("Cancel") 43 | element().assertHasLabel("Undo") 44 | } 45 | 46 | alert.buttons["Undo"].tap() 47 | stepper.assertHasValue("1") 48 | toggle.assertIsOff() 49 | } 50 | 51 | run("Undo second time") { 52 | shakeButton.tap() 53 | alert.assertHasLabel("Undo Change Int") 54 | alert.buttons.assertMatchedElements { element in 55 | element().assertHasLabel("Undo") 56 | element().assertHasLabel("Redo Change Bool") 57 | element().assertHasLabel("Cancel") 58 | } 59 | 60 | alert.buttons["Undo"].tap() 61 | stepper.assertHasValue("0") 62 | toggle.assertIsOff() 63 | } 64 | 65 | run("Redo first time") { 66 | shakeButton.tap() 67 | alert.assertHasLabel("Redo Change Int") 68 | alert.buttons.assertMatchedElements { element in 69 | element().assertHasLabel("Cancel") 70 | element().assertHasLabel("Redo") 71 | } 72 | 73 | alert.buttons["Redo"].tap() 74 | stepper.assertHasValue("1") 75 | toggle.assertIsOff() 76 | } 77 | 78 | run("Redo second time") { 79 | shakeButton.tap() 80 | alert.assertHasLabel("Undo Change Int") 81 | alert.buttons.assertMatchedElements { element in 82 | element().assertHasLabel("Undo") 83 | element().assertHasLabel("Redo Change Bool") 84 | element().assertHasLabel("Cancel") 85 | } 86 | alert.buttons["Redo Change Bool"].tap() 87 | stepper.assertHasValue("1") 88 | toggle.assertIsOn() 89 | } 90 | } 91 | 92 | func testDynamicActionName() throws { 93 | app.buttons["Advanced"].tap() 94 | 95 | run("Perform changes") { 96 | stepper.buttons["Increment"].tap() 97 | stepper.buttons["Decrement"].tap() 98 | toggle.switches.firstMatch.tap() 99 | } 100 | 101 | shakeButton.tap() 102 | alert.assertHasLabel("Undo Toggle") 103 | alert.buttons["Undo"].tap() 104 | 105 | shakeButton.tap() 106 | alert.assertHasLabel("Undo Decrement") 107 | alert.buttons["Undo"].tap() 108 | 109 | shakeButton.tap() 110 | alert.assertHasLabel("Undo Increment") 111 | } 112 | 113 | func testResetUndoStackWhenViewDisappears() throws { 114 | app.buttons["Simple"].tap() 115 | 116 | toggle.switches.firstMatch.tap() 117 | shakeButton.tap() 118 | alert.assertExists() 119 | alert.buttons["Cancel"].tap() 120 | 121 | app.navigationBars.buttons["Back"].tap() 122 | shakeButton.tapCenter() 123 | alert.assertNotExists() 124 | } 125 | 126 | private var stepper: XCUIElement { 127 | app.steppers.firstMatch 128 | } 129 | private var toggle: XCUIElement { 130 | app.switches.firstMatch 131 | } 132 | private var alert: XCUIElement { 133 | app.alerts.firstMatch 134 | } 135 | private var shakeButton: XCUIElement { 136 | app.buttons["Shake"] 137 | } 138 | } 139 | #endif 140 | -------------------------------------------------------------------------------- /Example/ExampleUITests/MacOSTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MacOSTests.swift 3 | // ExampleUITests 4 | // 5 | // Created by Łukasz Rutkowski on 23/05/2025. 6 | // 7 | 8 | #if os(macOS) 9 | import XCTest 10 | import XCAppTest 11 | 12 | final class MacOSTests: AppTestCase { 13 | func testUndoAndRedo() throws { 14 | app.buttons["Simple"].tap() 15 | 16 | run("Verify initial state") { 17 | stepper.assertHasValue(0) 18 | checkBox.assertIsOff() 19 | undoMenuItem.assertIsDisabled() 20 | redoMenuItem.assertIsDisabled() 21 | } 22 | 23 | run("Perform changes") { 24 | stepper.incrementArrows.firstMatch.tap() 25 | checkBox.tap() 26 | 27 | stepper.assertHasValue(1) 28 | checkBox.assertIsOn() 29 | undoMenuItem.assertIsEnabled() 30 | redoMenuItem.assertIsDisabled() 31 | } 32 | 33 | run("Undo first time") { 34 | undoMenuItem.tap() 35 | 36 | stepper.assertHasValue(1) 37 | checkBox.assertIsOff() 38 | undoMenuItem.assertIsEnabled() 39 | redoMenuItem.assertIsEnabled() 40 | } 41 | 42 | run("Undo second time") { 43 | undoMenuItem.tap() 44 | 45 | stepper.assertHasValue(0) 46 | checkBox.assertIsOff() 47 | undoMenuItem.assertIsDisabled() 48 | redoMenuItem.assertIsEnabled() 49 | } 50 | 51 | run("Redo first time") { 52 | redoMenuItem.tap() 53 | 54 | stepper.assertHasValue(1) 55 | checkBox.assertIsOff() 56 | undoMenuItem.assertIsEnabled() 57 | redoMenuItem.assertIsEnabled() 58 | } 59 | 60 | run("Redo second time") { 61 | redoMenuItem.tap() 62 | 63 | stepper.assertHasValue(1) 64 | checkBox.assertIsOn() 65 | undoMenuItem.assertIsEnabled() 66 | redoMenuItem.assertIsDisabled() 67 | } 68 | } 69 | 70 | private var stepper: XCUIElement { 71 | app.steppers.firstMatch 72 | } 73 | private var checkBox: XCUIElement { 74 | app.checkBoxes.firstMatch 75 | } 76 | private var undoMenuItem: XCUIElement { 77 | app.menuBarItems["Edit"].menuItems["undo:"] 78 | } 79 | private var redoMenuItem: XCUIElement { 80 | app.menuBarItems["Edit"].menuItems["redo:"] 81 | } 82 | } 83 | #endif 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Łukasz Rutkowski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SwiftUIUndo", 7 | platforms: [.iOS(.v17), .macOS(.v14)], 8 | products: [ 9 | .library( 10 | name: "SwiftUIUndo", 11 | targets: ["SwiftUIUndo"]), 12 | ], 13 | targets: [ 14 | .target( 15 | name: "SwiftUIUndo"), 16 | ] 17 | ) 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUIUndo 2 | 3 | A quick and easy way to add undo/redo functionality to SwiftUI apps. 4 | 5 | On iOS, this enables undo/redo via a shake gesture. 6 | On macOS, this enables undo/redo via menu commands and their keyboard shortcuts. 7 | 8 | ## Examples 9 | 10 | Call `withUndoRedo` on a visible SwiftUI view and provide a binding to an equatable value. 11 | 12 | ```swift 13 | struct SimpleExample: View { 14 | @State private var int = 0 15 | @State private var bool = false 16 | 17 | var body: some View { 18 | VStack { 19 | Stepper(int.formatted(), value: $int) 20 | .withUndoRedo("Change Int", for: $int) 21 | Toggle("Toggle", isOn: $bool) 22 | .withUndoRedo("Change Bool", for: $bool) 23 | } 24 | } 25 | } 26 | ``` 27 | 28 | You can dynamically decide the name of the action displayed to the user based on the changed values. 29 | 30 | ```swift 31 | struct AdvancedExample: View { 32 | struct Values: Equatable { 33 | var int: Int 34 | var bool: Bool 35 | } 36 | 37 | @State private var values = Values(int: 0, bool: false) 38 | 39 | var body: some View { 40 | VStack { 41 | Stepper(values.int.formatted(), value: $values.int) 42 | Toggle("Toggle", isOn: $values.bool) 43 | } 44 | .withUndoRedo(for: $values) { oldValues, newValues in 45 | if oldValues.int < newValues.int { 46 | return "Increment" 47 | } 48 | if oldValues.int > newValues.int { 49 | return "Decrement" 50 | } 51 | return "Toggle" 52 | } 53 | } 54 | } 55 | ``` 56 | 57 | If you do not want undo to activate via the shake gesture, set the `enableShakeToUndo` parameter to `false`. 58 | 59 | ## Installation 60 | 61 | ### Swift Package Manager 62 | 63 | 1. Add the following to the dependencies array in your `Package.swift` file: 64 | 65 | ```swift 66 | .package(url: "https://github.com/Tunous/SwiftUIUndo.git", .upToNextMajor(from: "1.0.0")), 67 | ``` 68 | 69 | 2. Add `SwiftUIUndo` as a dependency for your target: 70 | 71 | ```swift 72 | .target(name: "MyApp", dependencies: ["SwiftUIUndo"]), 73 | ``` 74 | 75 | 3. Add `import SwiftUIUndo` in your source code. 76 | 77 | ### Xcode 78 | 79 | Add [https://github.com/Tunous/SwiftUIUndo.git](https://github.com/Tunous/SwiftUIUndo.git) to the list of Swift packages for your project in Xcode and include it as a dependency for your target. 80 | -------------------------------------------------------------------------------- /Sources/SwiftUIUndo/Internal/DeviceShakeViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeviceShakeViewModifier.swift 3 | // MotoWeek 4 | // 5 | // Created by Łukasz Rutkowski on 10/05/2025. 6 | // 7 | 8 | #if canImport(UIKit) 9 | import SwiftUI 10 | import UIKit 11 | 12 | extension UIDevice { 13 | static let deviceDidShakeNotification = Notification.Name("swiftuiundo.deviceDidShakeNotification") 14 | 15 | @_spi(Shake) 16 | public static func notifyAboutShake() { 17 | NotificationCenter.default.post(name: UIDevice.deviceDidShakeNotification, object: nil) 18 | } 19 | } 20 | 21 | extension UIWindow { 22 | open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { 23 | super.motionEnded(motion, with: event) 24 | if motion == .motionShake { 25 | UIDevice.notifyAboutShake() 26 | } 27 | } 28 | } 29 | 30 | extension View { 31 | func onShake(perform action: @escaping () -> Void) -> some View { 32 | self.onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { _ in 33 | action() 34 | } 35 | } 36 | } 37 | #endif 38 | -------------------------------------------------------------------------------- /Sources/SwiftUIUndo/Internal/UndoHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UndoHandler.swift 3 | // MotoWeek 4 | // 5 | // Created by Łukasz Rutkowski on 10/05/2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @MainActor 11 | final class UndoHandler: ObservableObject { 12 | var binding: Binding? 13 | var isUndoing = false 14 | 15 | private weak var undoManger: UndoManager? 16 | 17 | init() {} 18 | 19 | func setup(with undoManager: UndoManager?) { 20 | cleanup() 21 | self.undoManger = undoManager 22 | } 23 | 24 | func cleanup() { 25 | undoManger?.removeAllActions(withTarget: self) 26 | } 27 | 28 | func registerUndo(from oldValue: Value, to newValue: Value) { 29 | undoManger?.registerUndo(withTarget: self) { handler in 30 | MainActor.assumeIsolated { 31 | handler.isUndoing = true 32 | handler.binding?.wrappedValue = oldValue 33 | handler.registerUndo(from: newValue, to: oldValue) 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/SwiftUIUndo/Internal/UndoManager+Util.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UndoManager+Util.swift 3 | // SwiftUIUndo 4 | // 5 | // Created by Łukasz Rutkowski on 22/05/2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension UndoManager { 11 | @MainActor 12 | private static let isShowingUndoRedoAlertKey = malloc(1)! 13 | 14 | @MainActor 15 | var isShowingUndoRedoAlert: Bool { 16 | get { 17 | objc_getAssociatedObject(self, Self.isShowingUndoRedoAlertKey) as? Bool == true 18 | } 19 | set { 20 | objc_setAssociatedObject(self, Self.isShowingUndoRedoAlertKey, newValue, .OBJC_ASSOCIATION_RETAIN) 21 | } 22 | } 23 | 24 | var undoMenuItemTitleWithoutActionName: String { 25 | undoMenuTitle(forUndoActionName: "").trimmingCharacters(in: .whitespaces) 26 | } 27 | 28 | var redoMenuItemTitleWithoutActionName: String { 29 | redoMenuTitle(forUndoActionName: "").trimmingCharacters(in: .whitespaces) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SwiftUIUndo/Internal/UndoRedoAwareModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UndoRedoAwareModifier.swift 3 | // MotoWeek 4 | // 5 | // Created by Łukasz Rutkowski on 10/05/2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UndoRedoAwareModifier: ViewModifier { 11 | @Binding var value: Value 12 | let enableShakeToUndo: Bool 13 | let actionName: (_ oldValue: Value, _ newValue: Value) -> String? 14 | 15 | @Environment(\.undoManager) private var undoManager 16 | @StateObject private var undoHandler = UndoHandler() 17 | 18 | func body(content: Content) -> some View { 19 | content 20 | .onChange(of: undoManager, initial: true) { 21 | undoHandler.setup(with: undoManager) 22 | } 23 | .onChange(of: value, handleValueChanged) 24 | .onDisappear { 25 | undoHandler.cleanup() 26 | } 27 | .modifier(UndoRedoShakeAlertModifier(enabled: enableShakeToUndo)) 28 | } 29 | 30 | private func handleValueChanged(oldValue: Value, newValue: Value) { 31 | if undoHandler.isUndoing { 32 | undoHandler.isUndoing = false 33 | return 34 | } 35 | 36 | undoHandler.binding = $value 37 | undoHandler.registerUndo(from: oldValue, to: newValue) 38 | if let name = actionName(oldValue, newValue) { 39 | undoManager?.setActionName(name) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SwiftUIUndo/Internal/UndoRedoShakeAlertModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UndoRedoShakeAlertModifier.swift 3 | // SwiftUIUndo 4 | // 5 | // Created by Łukasz Rutkowski on 22/05/2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UndoRedoShakeAlertModifier: ViewModifier { 11 | let enabled: Bool 12 | 13 | @Environment(\.undoManager) private var undoManager 14 | @State private var showAlert = false 15 | 16 | func body(content: Content) -> some View { 17 | #if canImport(UIKit) 18 | if enabled { 19 | content.onShake { 20 | guard let undoManager, UIAccessibility.isShakeToUndoEnabled, !undoManager.isShowingUndoRedoAlert else { return } 21 | showAlert = undoManager.canUndo || undoManager.canRedo 22 | undoManager.isShowingUndoRedoAlert = showAlert 23 | } 24 | .alert(alertTitle, isPresented: $showAlert) { 25 | alertBody 26 | } 27 | .onChange(of: showAlert) { 28 | undoManager?.isShowingUndoRedoAlert = showAlert 29 | } 30 | } else { 31 | content 32 | } 33 | #else 34 | content 35 | #endif 36 | } 37 | 38 | @ViewBuilder 39 | private var alertBody: some View { 40 | if let undoManager { 41 | if undoManager.canUndo { 42 | Button(undoManager.undoMenuItemTitleWithoutActionName) { 43 | undoManager.undo() 44 | } 45 | } 46 | if undoManager.canRedo { 47 | Button(undoManager.canUndo ? undoManager.redoMenuItemTitle : undoManager.redoMenuItemTitleWithoutActionName) { 48 | undoManager.redo() 49 | } 50 | } 51 | } 52 | Button("Cancel", role: .cancel) {} 53 | } 54 | 55 | private var alertTitle: String { 56 | guard let undoManager else { return "Undo" } 57 | return if undoManager.canUndo || !undoManager.canRedo { 58 | undoManager.undoMenuItemTitle 59 | } else { 60 | undoManager.redoMenuItemTitle 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/SwiftUIUndo/View+Undo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UndoRedoAction.swift 3 | // MotoWeek 4 | // 5 | // Created by Łukasz Rutkowski on 10/05/2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | 12 | /// Adds undo/redo functionality for changes made to the given `binding`. 13 | /// 14 | /// The undo/redo behavior delegates changes to system provided `UndoManager`. On macOS this will enable 15 | /// undo and redo menu commands together with their keyboard shortcuts. On iOS undo and redo will be available 16 | /// as a shake gesture, unless `enableShakeToUndo` is set to `false`. 17 | /// 18 | /// - Parameters: 19 | /// - binding: The value to observe for changes. 20 | /// - actionName: Optional action name. Used in UI to tell the user which action they are about to undo/redo. 21 | /// - enableShakeToUndo: Whether to enable shake to undo feature on iOS. 22 | public func withUndoRedo( 23 | _ actionName: (some StringProtocol)? = nil, 24 | for binding: Binding, 25 | enableShakeToUndo: Bool = true, 26 | ) -> some View { 27 | modifier(UndoRedoAwareModifier(value: binding, enableShakeToUndo: enableShakeToUndo) { _, _ in 28 | actionName.map { String($0) } 29 | }) 30 | } 31 | 32 | /// Adds undo/redo functionality for changes made to the given `binding`. 33 | /// 34 | /// The undo/redo behavior delegates changes to system provided `UndoManager`. On macOS this will enable 35 | /// undo and redo menu commands together with their keyboard shortcuts. On iOS undo and redo will be available 36 | /// as a shake gesture, unless `enableShakeToUndo` is set to `false`. 37 | /// 38 | /// - Parameters: 39 | /// - binding: The value to observe for changes. 40 | /// - enableShakeToUndo: Whether to enable shake to undo feature on iOS. 41 | /// - actionName: Action name provider used to decide what will be the name of the action displayed to the user. 42 | public func withUndoRedo( 43 | for binding: Binding, 44 | enableShakeToUndo: Bool = true, 45 | actionName: @escaping (_ oldValue: Value, _ newValue: Value) -> (some StringProtocol)?, 46 | ) -> some View { 47 | modifier(UndoRedoAwareModifier(value: binding, enableShakeToUndo: enableShakeToUndo) { oldValue, newValue in 48 | actionName(oldValue, newValue).map { String($0) } 49 | }) 50 | } 51 | } 52 | --------------------------------------------------------------------------------