├── .DS_Store ├── .gitignore ├── CoreDataCloudKitShare.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── xcshareddata │ └── xcschemes │ ├── CoreDataCloudKitShare.xcscheme │ ├── CoreDataCloudKitShareOnWatch.xcscheme │ └── InitializeCloudKitSchema.xcscheme ├── CoreDataCloudKitShare ├── .DS_Store ├── AppDelegate.swift ├── Assets.xcassets │ ├── .DS_Store │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── CoreDataCloudKitShare.entitlements ├── CoreDataCloudKitShareApp.swift ├── Info.plist ├── InitializeCloudKitSchema-Info.plist ├── PhotoPicker.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json └── SceneDelegate.swift ├── CoreDataCloudKitShareOnWatch WatchKit Extension ├── .DS_Store ├── Assets.xcassets │ ├── Complication.complicationset │ │ ├── Circular.imageset │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Extra Large.imageset │ │ │ └── Contents.json │ │ ├── Graphic Bezel.imageset │ │ │ └── Contents.json │ │ ├── Graphic Circular.imageset │ │ │ └── Contents.json │ │ ├── Graphic Corner.imageset │ │ │ └── Contents.json │ │ ├── Graphic Extra Large.imageset │ │ │ └── Contents.json │ │ ├── Graphic Large Rectangular.imageset │ │ │ └── Contents.json │ │ ├── Modular.imageset │ │ │ └── Contents.json │ │ └── Utilitarian.imageset │ │ │ └── Contents.json │ └── Contents.json ├── CoreDataCloudKitShareApp.swift ├── CoreDataCloudKitShareOnWatch WatchKit Extension.entitlements ├── ExtensionDelegate.swift ├── Info.plist └── Preview Content │ └── Preview Assets.xcassets │ └── Contents.json ├── CoreDataCloudKitShareOnWatch ├── .DS_Store ├── Assets.xcassets │ ├── .DS_Store │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json └── Info.plist ├── LICENSE ├── Persistence ├── .DS_Store ├── CoreDataCloudKitShare.xcdatamodeld │ ├── .xccurrentversion │ └── CoreDataCloudKitShare.xcdatamodel │ │ └── contents ├── CoreDataHelper.swift ├── PersistenceController+Deduplicate.swift ├── PersistenceController+History.swift ├── PersistenceController+Photo.swift ├── PersistenceController+Rating.swift ├── PersistenceController+Share.swift ├── PersistenceController+Tag.swift └── PersistenceController.swift ├── README.md └── SwiftUI ├── AddToExistingShareView.swift ├── FullImageView.swift ├── ManagingSharesView.swift ├── ParticipantView.swift ├── PhotoContextMenu.swift ├── PhotoGridItemView.swift ├── PhotoGridView.swift ├── RatingView.swift ├── SharePickerView.swift ├── SwiftUIHelper.swift └── TaggingView.swift /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delawaremathguy/CoreDataCloudKitShare/5a1894c60591c7005905f956a11a58eada1a92ec/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 401C06ED276E9D790024D92C /* ExtensionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C06EC276E9D790024D92C /* ExtensionDelegate.swift */; }; 11 | 4026B868270A19390060F0F7 /* TaggingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4026B867270A19390060F0F7 /* TaggingView.swift */; }; 12 | 403177D32742D5790048F2DC /* RatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40527E16270BBE3400DB2901 /* RatingView.swift */; }; 13 | 403177D42742DB270048F2DC /* TaggingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4026B867270A19390060F0F7 /* TaggingView.swift */; }; 14 | 403177D62742F4B40048F2DC /* PhotoGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D622703A27000D2BA90 /* PhotoGridItemView.swift */; }; 15 | 403177D82742F4F70048F2DC /* SwiftUIHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403177D72742F4F70048F2DC /* SwiftUIHelper.swift */; }; 16 | 403177D92742F4F70048F2DC /* SwiftUIHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403177D72742F4F70048F2DC /* SwiftUIHelper.swift */; }; 17 | 403177DA27430A800048F2DC /* PhotoGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7CED27024EBD00D2BA90 /* PhotoGridView.swift */; }; 18 | 403177DB27430E340048F2DC /* ParticipantView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40744288270FAFB3009CABC7 /* ParticipantView.swift */; }; 19 | 403177DC274310690048F2DC /* AddToExistingShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408BD4D92713B29900294A81 /* AddToExistingShareView.swift */; }; 20 | 403177DD274310B50048F2DC /* ManagingSharesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408BD4DB2713F0F400294A81 /* ManagingSharesView.swift */; }; 21 | 403177DE27432CE40048F2DC /* PhotoContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4035B60D27150DA100F46D6B /* PhotoContextMenu.swift */; }; 22 | 4035B60E27150DA100F46D6B /* PhotoContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4035B60D27150DA100F46D6B /* PhotoContextMenu.swift */; }; 23 | 40527E17270BBE3400DB2901 /* RatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40527E16270BBE3400DB2901 /* RatingView.swift */; }; 24 | 40527E19270BC8FE00DB2901 /* PersistenceController+Rating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40527E18270BC8FE00DB2901 /* PersistenceController+Rating.swift */; }; 25 | 4058316A2767DCDE0044E86D /* FullImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405831692767DCDE0044E86D /* FullImageView.swift */; }; 26 | 4058316B2767F0ED0044E86D /* FullImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405831692767DCDE0044E86D /* FullImageView.swift */; }; 27 | 40744289270FAFB3009CABC7 /* ParticipantView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40744288270FAFB3009CABC7 /* ParticipantView.swift */; }; 28 | 4074428B270FB2B5009CABC7 /* PersistenceController+Share.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4074428A270FB2B5009CABC7 /* PersistenceController+Share.swift */; }; 29 | 407A0CDE2707D0CD00F481C5 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D6A2704334400D2BA90 /* SceneDelegate.swift */; }; 30 | 407A0CE02707D0CD00F481C5 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D6C2704335A00D2BA90 /* AppDelegate.swift */; }; 31 | 407A0CE12707D0CD00F481C5 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D0027024F5D00D2BA90 /* PersistenceController.swift */; }; 32 | 407A0CEA2707D0CD00F481C5 /* CoreDataCloudKitShare.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 407D7CF627024EBE00D2BA90 /* CoreDataCloudKitShare.xcdatamodeld */; }; 33 | 407A0CED2707D0CD00F481C5 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 407D7D092702502400D2BA90 /* CloudKit.framework */; }; 34 | 407A0CEF2707D0CD00F481C5 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 407D7CF227024EBE00D2BA90 /* Preview Assets.xcassets */; }; 35 | 407A0CF02707D0CD00F481C5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 407D7CEF27024EBE00D2BA90 /* Assets.xcassets */; }; 36 | 407D7CEC27024EBD00D2BA90 /* CoreDataCloudKitShareApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7CEB27024EBD00D2BA90 /* CoreDataCloudKitShareApp.swift */; }; 37 | 407D7CEE27024EBD00D2BA90 /* PhotoGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7CED27024EBD00D2BA90 /* PhotoGridView.swift */; }; 38 | 407D7CF027024EBE00D2BA90 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 407D7CEF27024EBE00D2BA90 /* Assets.xcassets */; }; 39 | 407D7CF327024EBE00D2BA90 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 407D7CF227024EBE00D2BA90 /* Preview Assets.xcassets */; }; 40 | 407D7CF827024EBE00D2BA90 /* CoreDataCloudKitShare.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 407D7CF627024EBE00D2BA90 /* CoreDataCloudKitShare.xcdatamodeld */; }; 41 | 407D7D0427024F5D00D2BA90 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D0027024F5D00D2BA90 /* PersistenceController.swift */; }; 42 | 407D7D0A2702502400D2BA90 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 407D7D092702502400D2BA90 /* CloudKit.framework */; }; 43 | 407D7D102702556700D2BA90 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 407D7D0F2702556700D2BA90 /* Assets.xcassets */; }; 44 | 407D7D162702556800D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 407D7D152702556800D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 45 | 407D7D1B2702556800D2BA90 /* CoreDataCloudKitShareApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D1A2702556800D2BA90 /* CoreDataCloudKitShareApp.swift */; }; 46 | 407D7D252702556900D2BA90 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 407D7D242702556900D2BA90 /* Assets.xcassets */; }; 47 | 407D7D282702556900D2BA90 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 407D7D272702556900D2BA90 /* Preview Assets.xcassets */; }; 48 | 407D7D2D2702556900D2BA90 /* CoreDataCloudKitShareOnWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 407D7D0D2702556600D2BA90 /* CoreDataCloudKitShareOnWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 49 | 407D7D39270262EF00D2BA90 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 407D7D38270262EF00D2BA90 /* CloudKit.framework */; }; 50 | 407D7D3B2702A2A400D2BA90 /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D3A2702A2A400D2BA90 /* PhotoPicker.swift */; }; 51 | 407D7D5F2703878000D2BA90 /* PersistenceController+Photo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D5E2703878000D2BA90 /* PersistenceController+Photo.swift */; }; 52 | 407D7D612703880E00D2BA90 /* PersistenceController+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D602703880E00D2BA90 /* PersistenceController+Tag.swift */; }; 53 | 407D7D632703A27000D2BA90 /* PhotoGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D622703A27000D2BA90 /* PhotoGridItemView.swift */; }; 54 | 407D7D6B2704334400D2BA90 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D6A2704334400D2BA90 /* SceneDelegate.swift */; }; 55 | 407D7D6D2704335A00D2BA90 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D6C2704335A00D2BA90 /* AppDelegate.swift */; }; 56 | 408BD4DA2713B29900294A81 /* AddToExistingShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408BD4D92713B29900294A81 /* AddToExistingShareView.swift */; }; 57 | 408BD4DC2713F0F400294A81 /* ManagingSharesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408BD4DB2713F0F400294A81 /* ManagingSharesView.swift */; }; 58 | 40AE53F12719F98C00B978CA /* CoreDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40AE53F02719F98C00B978CA /* CoreDataHelper.swift */; }; 59 | 40B6201F273AFD3400B27D3D /* CoreDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40AE53F02719F98C00B978CA /* CoreDataHelper.swift */; }; 60 | 40B62020273AFD3700B27D3D /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D0027024F5D00D2BA90 /* PersistenceController.swift */; }; 61 | 40B62021273AFD6700B27D3D /* CoreDataCloudKitShare.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 407D7CF627024EBE00D2BA90 /* CoreDataCloudKitShare.xcdatamodeld */; }; 62 | 40B62022273AFD9300B27D3D /* PersistenceController+Deduplicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D68E932719101A00FB9B78 /* PersistenceController+Deduplicate.swift */; }; 63 | 40B62023273AFD9700B27D3D /* PersistenceController+Rating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40527E18270BC8FE00DB2901 /* PersistenceController+Rating.swift */; }; 64 | 40B62024273AFD9900B27D3D /* PersistenceController+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D602703880E00D2BA90 /* PersistenceController+Tag.swift */; }; 65 | 40B62027273AFDC600B27D3D /* PersistenceController+Photo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7D5E2703878000D2BA90 /* PersistenceController+Photo.swift */; }; 66 | 40BACC0B2740646E00F12CBD /* PersistenceController+Share.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4074428A270FB2B5009CABC7 /* PersistenceController+Share.swift */; }; 67 | 40BF6728273B0B7400ED9D2D /* CoreDataCloudKitShareApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D7CEB27024EBD00D2BA90 /* CoreDataCloudKitShareApp.swift */; }; 68 | 40BF672E273B17A500ED9D2D /* PersistenceController+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40BF672C273B17A200ED9D2D /* PersistenceController+History.swift */; }; 69 | 40BF672F273B17A900ED9D2D /* PersistenceController+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40BF672C273B17A200ED9D2D /* PersistenceController+History.swift */; }; 70 | 40C6211E2755AFB500015301 /* SharePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C6211D2755AFB500015301 /* SharePickerView.swift */; }; 71 | 40C6211F2755AFB500015301 /* SharePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C6211D2755AFB500015301 /* SharePickerView.swift */; }; 72 | 40D68E942719101A00FB9B78 /* PersistenceController+Deduplicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D68E932719101A00FB9B78 /* PersistenceController+Deduplicate.swift */; }; 73 | /* End PBXBuildFile section */ 74 | 75 | /* Begin PBXContainerItemProxy section */ 76 | 407D7D172702556800D2BA90 /* PBXContainerItemProxy */ = { 77 | isa = PBXContainerItemProxy; 78 | containerPortal = 407D7CE027024EBD00D2BA90 /* Project object */; 79 | proxyType = 1; 80 | remoteGlobalIDString = 407D7D142702556800D2BA90; 81 | remoteInfo = "CoreDataCloudKitShareOnWatch WatchKit Extension"; 82 | }; 83 | 407D7D2B2702556900D2BA90 /* PBXContainerItemProxy */ = { 84 | isa = PBXContainerItemProxy; 85 | containerPortal = 407D7CE027024EBD00D2BA90 /* Project object */; 86 | proxyType = 1; 87 | remoteGlobalIDString = 407D7D0C2702556600D2BA90; 88 | remoteInfo = CoreDataCloudKitShareOnWatch; 89 | }; 90 | /* End PBXContainerItemProxy section */ 91 | 92 | /* Begin PBXCopyFilesBuildPhase section */ 93 | 407A0CF12707D0CD00F481C5 /* Embed Watch Content */ = { 94 | isa = PBXCopyFilesBuildPhase; 95 | buildActionMask = 2147483647; 96 | dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; 97 | dstSubfolderSpec = 16; 98 | files = ( 99 | ); 100 | name = "Embed Watch Content"; 101 | runOnlyForDeploymentPostprocessing = 0; 102 | }; 103 | 407D7D2E2702556900D2BA90 /* Embed Watch Content */ = { 104 | isa = PBXCopyFilesBuildPhase; 105 | buildActionMask = 2147483647; 106 | dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; 107 | dstSubfolderSpec = 16; 108 | files = ( 109 | 407D7D2D2702556900D2BA90 /* CoreDataCloudKitShareOnWatch.app in Embed Watch Content */, 110 | ); 111 | name = "Embed Watch Content"; 112 | runOnlyForDeploymentPostprocessing = 0; 113 | }; 114 | 407D7D312702556900D2BA90 /* Embed App Extensions */ = { 115 | isa = PBXCopyFilesBuildPhase; 116 | buildActionMask = 2147483647; 117 | dstPath = ""; 118 | dstSubfolderSpec = 13; 119 | files = ( 120 | 407D7D162702556800D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension.appex in Embed App Extensions */, 121 | ); 122 | name = "Embed App Extensions"; 123 | runOnlyForDeploymentPostprocessing = 0; 124 | }; 125 | /* End PBXCopyFilesBuildPhase section */ 126 | 127 | /* Begin PBXFileReference section */ 128 | 401C06EC276E9D790024D92C /* ExtensionDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExtensionDelegate.swift; sourceTree = ""; }; 129 | 4026B867270A19390060F0F7 /* TaggingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaggingView.swift; sourceTree = ""; }; 130 | 403177D72742F4F70048F2DC /* SwiftUIHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIHelper.swift; sourceTree = ""; }; 131 | 4035B60D27150DA100F46D6B /* PhotoContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoContextMenu.swift; sourceTree = ""; }; 132 | 40527E16270BBE3400DB2901 /* RatingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingView.swift; sourceTree = ""; }; 133 | 40527E18270BC8FE00DB2901 /* PersistenceController+Rating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceController+Rating.swift"; sourceTree = ""; }; 134 | 405831692767DCDE0044E86D /* FullImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullImageView.swift; sourceTree = ""; }; 135 | 406193C22755686C006EC5D8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 136 | 40744288270FAFB3009CABC7 /* ParticipantView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantView.swift; sourceTree = ""; }; 137 | 4074428A270FB2B5009CABC7 /* PersistenceController+Share.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceController+Share.swift"; sourceTree = ""; }; 138 | 407A0CF62707D0CD00F481C5 /* InitializeCloudKitSchema.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InitializeCloudKitSchema.app; sourceTree = BUILT_PRODUCTS_DIR; }; 139 | 407D7CE827024EBD00D2BA90 /* CoreDataCloudKitShare.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CoreDataCloudKitShare.app; sourceTree = BUILT_PRODUCTS_DIR; }; 140 | 407D7CEB27024EBD00D2BA90 /* CoreDataCloudKitShareApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataCloudKitShareApp.swift; sourceTree = ""; }; 141 | 407D7CED27024EBD00D2BA90 /* PhotoGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoGridView.swift; sourceTree = ""; }; 142 | 407D7CEF27024EBE00D2BA90 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 143 | 407D7CF227024EBE00D2BA90 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 144 | 407D7CF727024EBE00D2BA90 /* CoreDataCloudKitShare.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreDataCloudKitShare.xcdatamodel; sourceTree = ""; }; 145 | 407D7CFE27024F4100D2BA90 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 146 | 407D7D0027024F5D00D2BA90 /* PersistenceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; 147 | 407D7D072702502200D2BA90 /* CoreDataCloudKitShare.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoreDataCloudKitShare.entitlements; sourceTree = ""; }; 148 | 407D7D092702502400D2BA90 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; 149 | 407D7D0D2702556600D2BA90 /* CoreDataCloudKitShareOnWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CoreDataCloudKitShareOnWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 150 | 407D7D0F2702556700D2BA90 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 151 | 407D7D152702556800D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "CoreDataCloudKitShareOnWatch WatchKit Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 152 | 407D7D1A2702556800D2BA90 /* CoreDataCloudKitShareApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataCloudKitShareApp.swift; sourceTree = ""; }; 153 | 407D7D242702556900D2BA90 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 154 | 407D7D272702556900D2BA90 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 155 | 407D7D292702556900D2BA90 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 156 | 407D7D36270262C500D2BA90 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 157 | 407D7D37270262ED00D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "CoreDataCloudKitShareOnWatch WatchKit Extension.entitlements"; sourceTree = ""; }; 158 | 407D7D38270262EF00D2BA90 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS8.0.sdk/System/Library/Frameworks/CloudKit.framework; sourceTree = DEVELOPER_DIR; }; 159 | 407D7D3A2702A2A400D2BA90 /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; }; 160 | 407D7D5E2703878000D2BA90 /* PersistenceController+Photo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceController+Photo.swift"; sourceTree = ""; }; 161 | 407D7D602703880E00D2BA90 /* PersistenceController+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceController+Tag.swift"; sourceTree = ""; }; 162 | 407D7D622703A27000D2BA90 /* PhotoGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoGridItemView.swift; sourceTree = ""; }; 163 | 407D7D6A2704334400D2BA90 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 164 | 407D7D6C2704335A00D2BA90 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 165 | 408BD4D92713B29900294A81 /* AddToExistingShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToExistingShareView.swift; sourceTree = ""; }; 166 | 408BD4DB2713F0F400294A81 /* ManagingSharesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagingSharesView.swift; sourceTree = ""; }; 167 | 40AE53F02719F98C00B978CA /* CoreDataHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelper.swift; sourceTree = ""; }; 168 | 40BF672C273B17A200ED9D2D /* PersistenceController+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceController+History.swift"; sourceTree = ""; }; 169 | 40C6211D2755AFB500015301 /* SharePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePickerView.swift; sourceTree = ""; }; 170 | 40D68E932719101A00FB9B78 /* PersistenceController+Deduplicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceController+Deduplicate.swift"; sourceTree = ""; }; 171 | /* End PBXFileReference section */ 172 | 173 | /* Begin PBXFrameworksBuildPhase section */ 174 | 407A0CEC2707D0CD00F481C5 /* Frameworks */ = { 175 | isa = PBXFrameworksBuildPhase; 176 | buildActionMask = 2147483647; 177 | files = ( 178 | 407A0CED2707D0CD00F481C5 /* CloudKit.framework in Frameworks */, 179 | ); 180 | runOnlyForDeploymentPostprocessing = 0; 181 | }; 182 | 407D7CE527024EBD00D2BA90 /* Frameworks */ = { 183 | isa = PBXFrameworksBuildPhase; 184 | buildActionMask = 2147483647; 185 | files = ( 186 | 407D7D0A2702502400D2BA90 /* CloudKit.framework in Frameworks */, 187 | ); 188 | runOnlyForDeploymentPostprocessing = 0; 189 | }; 190 | 407D7D122702556800D2BA90 /* Frameworks */ = { 191 | isa = PBXFrameworksBuildPhase; 192 | buildActionMask = 2147483647; 193 | files = ( 194 | 407D7D39270262EF00D2BA90 /* CloudKit.framework in Frameworks */, 195 | ); 196 | runOnlyForDeploymentPostprocessing = 0; 197 | }; 198 | /* End PBXFrameworksBuildPhase section */ 199 | 200 | /* Begin PBXGroup section */ 201 | 403177D52742DF030048F2DC /* SwiftUI */ = { 202 | isa = PBXGroup; 203 | children = ( 204 | 403177D72742F4F70048F2DC /* SwiftUIHelper.swift */, 205 | 407D7CED27024EBD00D2BA90 /* PhotoGridView.swift */, 206 | 407D7D622703A27000D2BA90 /* PhotoGridItemView.swift */, 207 | 4035B60D27150DA100F46D6B /* PhotoContextMenu.swift */, 208 | 4026B867270A19390060F0F7 /* TaggingView.swift */, 209 | 40527E16270BBE3400DB2901 /* RatingView.swift */, 210 | 40744288270FAFB3009CABC7 /* ParticipantView.swift */, 211 | 408BD4D92713B29900294A81 /* AddToExistingShareView.swift */, 212 | 408BD4DB2713F0F400294A81 /* ManagingSharesView.swift */, 213 | 40C6211D2755AFB500015301 /* SharePickerView.swift */, 214 | 405831692767DCDE0044E86D /* FullImageView.swift */, 215 | ); 216 | path = SwiftUI; 217 | sourceTree = ""; 218 | }; 219 | 407D7CDF27024EBD00D2BA90 = { 220 | isa = PBXGroup; 221 | children = ( 222 | 407D7CFE27024F4100D2BA90 /* README.md */, 223 | 407D7CFF27024F5D00D2BA90 /* Persistence */, 224 | 403177D52742DF030048F2DC /* SwiftUI */, 225 | 407D7CEA27024EBD00D2BA90 /* CoreDataCloudKitShare */, 226 | 407D7D0E2702556600D2BA90 /* CoreDataCloudKitShareOnWatch */, 227 | 407D7D192702556800D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension */, 228 | 407D7CE927024EBD00D2BA90 /* Products */, 229 | 407D7D082702502400D2BA90 /* Frameworks */, 230 | ); 231 | sourceTree = ""; 232 | }; 233 | 407D7CE927024EBD00D2BA90 /* Products */ = { 234 | isa = PBXGroup; 235 | children = ( 236 | 407D7CE827024EBD00D2BA90 /* CoreDataCloudKitShare.app */, 237 | 407D7D0D2702556600D2BA90 /* CoreDataCloudKitShareOnWatch.app */, 238 | 407D7D152702556800D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension.appex */, 239 | 407A0CF62707D0CD00F481C5 /* InitializeCloudKitSchema.app */, 240 | ); 241 | name = Products; 242 | sourceTree = ""; 243 | }; 244 | 407D7CEA27024EBD00D2BA90 /* CoreDataCloudKitShare */ = { 245 | isa = PBXGroup; 246 | children = ( 247 | 407D7D072702502200D2BA90 /* CoreDataCloudKitShare.entitlements */, 248 | 407D7D3A2702A2A400D2BA90 /* PhotoPicker.swift */, 249 | 407D7CEB27024EBD00D2BA90 /* CoreDataCloudKitShareApp.swift */, 250 | 407D7D6C2704335A00D2BA90 /* AppDelegate.swift */, 251 | 407D7D6A2704334400D2BA90 /* SceneDelegate.swift */, 252 | 407D7D36270262C500D2BA90 /* Info.plist */, 253 | 407D7CEF27024EBE00D2BA90 /* Assets.xcassets */, 254 | 407D7CF127024EBE00D2BA90 /* Preview Content */, 255 | ); 256 | path = CoreDataCloudKitShare; 257 | sourceTree = ""; 258 | }; 259 | 407D7CF127024EBE00D2BA90 /* Preview Content */ = { 260 | isa = PBXGroup; 261 | children = ( 262 | 407D7CF227024EBE00D2BA90 /* Preview Assets.xcassets */, 263 | ); 264 | path = "Preview Content"; 265 | sourceTree = ""; 266 | }; 267 | 407D7CFF27024F5D00D2BA90 /* Persistence */ = { 268 | isa = PBXGroup; 269 | children = ( 270 | 40AE53F02719F98C00B978CA /* CoreDataHelper.swift */, 271 | 407D7D0027024F5D00D2BA90 /* PersistenceController.swift */, 272 | 407D7D5E2703878000D2BA90 /* PersistenceController+Photo.swift */, 273 | 407D7D602703880E00D2BA90 /* PersistenceController+Tag.swift */, 274 | 40527E18270BC8FE00DB2901 /* PersistenceController+Rating.swift */, 275 | 40BF672C273B17A200ED9D2D /* PersistenceController+History.swift */, 276 | 40D68E932719101A00FB9B78 /* PersistenceController+Deduplicate.swift */, 277 | 4074428A270FB2B5009CABC7 /* PersistenceController+Share.swift */, 278 | 407D7CF627024EBE00D2BA90 /* CoreDataCloudKitShare.xcdatamodeld */, 279 | ); 280 | path = Persistence; 281 | sourceTree = ""; 282 | }; 283 | 407D7D082702502400D2BA90 /* Frameworks */ = { 284 | isa = PBXGroup; 285 | children = ( 286 | 407D7D38270262EF00D2BA90 /* CloudKit.framework */, 287 | 407D7D092702502400D2BA90 /* CloudKit.framework */, 288 | ); 289 | name = Frameworks; 290 | sourceTree = ""; 291 | }; 292 | 407D7D0E2702556600D2BA90 /* CoreDataCloudKitShareOnWatch */ = { 293 | isa = PBXGroup; 294 | children = ( 295 | 406193C22755686C006EC5D8 /* Info.plist */, 296 | 407D7D0F2702556700D2BA90 /* Assets.xcassets */, 297 | ); 298 | path = CoreDataCloudKitShareOnWatch; 299 | sourceTree = ""; 300 | }; 301 | 407D7D192702556800D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension */ = { 302 | isa = PBXGroup; 303 | children = ( 304 | 407D7D37270262ED00D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension.entitlements */, 305 | 407D7D1A2702556800D2BA90 /* CoreDataCloudKitShareApp.swift */, 306 | 401C06EC276E9D790024D92C /* ExtensionDelegate.swift */, 307 | 407D7D242702556900D2BA90 /* Assets.xcassets */, 308 | 407D7D292702556900D2BA90 /* Info.plist */, 309 | 407D7D262702556900D2BA90 /* Preview Content */, 310 | ); 311 | path = "CoreDataCloudKitShareOnWatch WatchKit Extension"; 312 | sourceTree = ""; 313 | }; 314 | 407D7D262702556900D2BA90 /* Preview Content */ = { 315 | isa = PBXGroup; 316 | children = ( 317 | 407D7D272702556900D2BA90 /* Preview Assets.xcassets */, 318 | ); 319 | path = "Preview Content"; 320 | sourceTree = ""; 321 | }; 322 | /* End PBXGroup section */ 323 | 324 | /* Begin PBXNativeTarget section */ 325 | 407A0CD92707D0CD00F481C5 /* InitializeCloudKitSchema */ = { 326 | isa = PBXNativeTarget; 327 | buildConfigurationList = 407A0CF32707D0CD00F481C5 /* Build configuration list for PBXNativeTarget "InitializeCloudKitSchema" */; 328 | buildPhases = ( 329 | 407A0CDC2707D0CD00F481C5 /* Sources */, 330 | 407A0CEC2707D0CD00F481C5 /* Frameworks */, 331 | 407A0CEE2707D0CD00F481C5 /* Resources */, 332 | 407A0CF12707D0CD00F481C5 /* Embed Watch Content */, 333 | F285BB193DC48B9B86804FEF /* Winnow */, 334 | ); 335 | buildRules = ( 336 | ); 337 | dependencies = ( 338 | ); 339 | name = InitializeCloudKitSchema; 340 | productName = CoreDataCloudKitShare; 341 | productReference = 407A0CF62707D0CD00F481C5 /* InitializeCloudKitSchema.app */; 342 | productType = "com.apple.product-type.application"; 343 | }; 344 | 407D7CE727024EBD00D2BA90 /* CoreDataCloudKitShare */ = { 345 | isa = PBXNativeTarget; 346 | buildConfigurationList = 407D7CFB27024EBE00D2BA90 /* Build configuration list for PBXNativeTarget "CoreDataCloudKitShare" */; 347 | buildPhases = ( 348 | 407D7CE427024EBD00D2BA90 /* Sources */, 349 | 407D7CE527024EBD00D2BA90 /* Frameworks */, 350 | 407D7CE627024EBD00D2BA90 /* Resources */, 351 | 407D7D2E2702556900D2BA90 /* Embed Watch Content */, 352 | 69D6E6F747E210AF1CB2FC19 /* Winnow */, 353 | ); 354 | buildRules = ( 355 | ); 356 | dependencies = ( 357 | 407D7D2C2702556900D2BA90 /* PBXTargetDependency */, 358 | ); 359 | name = CoreDataCloudKitShare; 360 | productName = CoreDataCloudKitShare; 361 | productReference = 407D7CE827024EBD00D2BA90 /* CoreDataCloudKitShare.app */; 362 | productType = "com.apple.product-type.application"; 363 | }; 364 | 407D7D0C2702556600D2BA90 /* CoreDataCloudKitShareOnWatch */ = { 365 | isa = PBXNativeTarget; 366 | buildConfigurationList = 407D7D352702556900D2BA90 /* Build configuration list for PBXNativeTarget "CoreDataCloudKitShareOnWatch" */; 367 | buildPhases = ( 368 | 407D7D0B2702556600D2BA90 /* Resources */, 369 | 407D7D312702556900D2BA90 /* Embed App Extensions */, 370 | 18882F01A9DBF0DA5A276833 /* Winnow */, 371 | ); 372 | buildRules = ( 373 | ); 374 | dependencies = ( 375 | 407D7D182702556800D2BA90 /* PBXTargetDependency */, 376 | ); 377 | name = CoreDataCloudKitShareOnWatch; 378 | productName = CoreDataCloudKitShareOnWatch; 379 | productReference = 407D7D0D2702556600D2BA90 /* CoreDataCloudKitShareOnWatch.app */; 380 | productType = "com.apple.product-type.application.watchapp2"; 381 | }; 382 | 407D7D142702556800D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension */ = { 383 | isa = PBXNativeTarget; 384 | buildConfigurationList = 407D7D342702556900D2BA90 /* Build configuration list for PBXNativeTarget "CoreDataCloudKitShareOnWatch WatchKit Extension" */; 385 | buildPhases = ( 386 | 407D7D112702556800D2BA90 /* Sources */, 387 | 407D7D122702556800D2BA90 /* Frameworks */, 388 | 407D7D132702556800D2BA90 /* Resources */, 389 | 3215D275E631E661EDB54362 /* Winnow */, 390 | ); 391 | buildRules = ( 392 | ); 393 | dependencies = ( 394 | ); 395 | name = "CoreDataCloudKitShareOnWatch WatchKit Extension"; 396 | productName = "CoreDataCloudKitShareOnWatch WatchKit Extension"; 397 | productReference = 407D7D152702556800D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension.appex */; 398 | productType = "com.apple.product-type.watchkit2-extension"; 399 | }; 400 | /* End PBXNativeTarget section */ 401 | 402 | /* Begin PBXProject section */ 403 | 407D7CE027024EBD00D2BA90 /* Project object */ = { 404 | isa = PBXProject; 405 | attributes = { 406 | BuildIndependentTargetsInParallel = 1; 407 | LastSwiftUpdateCheck = 1300; 408 | LastUpgradeCheck = 1300; 409 | TargetAttributes = { 410 | 407D7CE727024EBD00D2BA90 = { 411 | CreatedOnToolsVersion = 13.0; 412 | }; 413 | 407D7D0C2702556600D2BA90 = { 414 | CreatedOnToolsVersion = 13.0; 415 | }; 416 | 407D7D142702556800D2BA90 = { 417 | CreatedOnToolsVersion = 13.0; 418 | }; 419 | }; 420 | }; 421 | buildConfigurationList = 407D7CE327024EBD00D2BA90 /* Build configuration list for PBXProject "CoreDataCloudKitShare" */; 422 | compatibilityVersion = "Xcode 13.0"; 423 | developmentRegion = en; 424 | hasScannedForEncodings = 0; 425 | knownRegions = ( 426 | en, 427 | Base, 428 | ); 429 | mainGroup = 407D7CDF27024EBD00D2BA90; 430 | productRefGroup = 407D7CE927024EBD00D2BA90 /* Products */; 431 | projectDirPath = ""; 432 | projectRoot = ""; 433 | targets = ( 434 | 407D7CE727024EBD00D2BA90 /* CoreDataCloudKitShare */, 435 | 407A0CD92707D0CD00F481C5 /* InitializeCloudKitSchema */, 436 | 407D7D0C2702556600D2BA90 /* CoreDataCloudKitShareOnWatch */, 437 | 407D7D142702556800D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension */, 438 | ); 439 | }; 440 | /* End PBXProject section */ 441 | 442 | /* Begin PBXResourcesBuildPhase section */ 443 | 407A0CEE2707D0CD00F481C5 /* Resources */ = { 444 | isa = PBXResourcesBuildPhase; 445 | buildActionMask = 2147483647; 446 | files = ( 447 | 407A0CEF2707D0CD00F481C5 /* Preview Assets.xcassets in Resources */, 448 | 407A0CF02707D0CD00F481C5 /* Assets.xcassets in Resources */, 449 | ); 450 | runOnlyForDeploymentPostprocessing = 0; 451 | }; 452 | 407D7CE627024EBD00D2BA90 /* Resources */ = { 453 | isa = PBXResourcesBuildPhase; 454 | buildActionMask = 2147483647; 455 | files = ( 456 | 407D7CF327024EBE00D2BA90 /* Preview Assets.xcassets in Resources */, 457 | 407D7CF027024EBE00D2BA90 /* Assets.xcassets in Resources */, 458 | ); 459 | runOnlyForDeploymentPostprocessing = 0; 460 | }; 461 | 407D7D0B2702556600D2BA90 /* Resources */ = { 462 | isa = PBXResourcesBuildPhase; 463 | buildActionMask = 2147483647; 464 | files = ( 465 | 407D7D102702556700D2BA90 /* Assets.xcassets in Resources */, 466 | ); 467 | runOnlyForDeploymentPostprocessing = 0; 468 | }; 469 | 407D7D132702556800D2BA90 /* Resources */ = { 470 | isa = PBXResourcesBuildPhase; 471 | buildActionMask = 2147483647; 472 | files = ( 473 | 407D7D282702556900D2BA90 /* Preview Assets.xcassets in Resources */, 474 | 407D7D252702556900D2BA90 /* Assets.xcassets in Resources */, 475 | ); 476 | runOnlyForDeploymentPostprocessing = 0; 477 | }; 478 | /* End PBXResourcesBuildPhase section */ 479 | 480 | /* Begin PBXShellScriptBuildPhase section */ 481 | 18882F01A9DBF0DA5A276833 /* Winnow */ = { 482 | isa = PBXShellScriptBuildPhase; 483 | buildActionMask = 2147483647; 484 | files = ( 485 | ); 486 | inputPaths = ( 487 | ); 488 | name = Winnow; 489 | outputPaths = ( 490 | ); 491 | runOnlyForDeploymentPostprocessing = 0; 492 | shellPath = /bin/sh; 493 | shellScript = "#$(dirname \"$(which winnow)\")/Winnow.app/Contents/Resources/winnow_run_script.sh\n"; 494 | }; 495 | 3215D275E631E661EDB54362 /* Winnow */ = { 496 | isa = PBXShellScriptBuildPhase; 497 | buildActionMask = 2147483647; 498 | files = ( 499 | ); 500 | inputPaths = ( 501 | ); 502 | name = Winnow; 503 | outputPaths = ( 504 | ); 505 | runOnlyForDeploymentPostprocessing = 0; 506 | shellPath = /bin/sh; 507 | shellScript = "#$(dirname \"$(which winnow)\")/Winnow.app/Contents/Resources/winnow_run_script.sh\n"; 508 | }; 509 | 69D6E6F747E210AF1CB2FC19 /* Winnow */ = { 510 | isa = PBXShellScriptBuildPhase; 511 | buildActionMask = 2147483647; 512 | files = ( 513 | ); 514 | inputPaths = ( 515 | ); 516 | name = Winnow; 517 | outputPaths = ( 518 | ); 519 | runOnlyForDeploymentPostprocessing = 0; 520 | shellPath = /bin/sh; 521 | shellScript = "#$(dirname \"$(which winnow)\")/Winnow.app/Contents/Resources/winnow_run_script.sh\n"; 522 | }; 523 | F285BB193DC48B9B86804FEF /* Winnow */ = { 524 | isa = PBXShellScriptBuildPhase; 525 | buildActionMask = 2147483647; 526 | files = ( 527 | ); 528 | inputPaths = ( 529 | ); 530 | name = Winnow; 531 | outputPaths = ( 532 | ); 533 | runOnlyForDeploymentPostprocessing = 0; 534 | shellPath = /bin/sh; 535 | shellScript = "#$(dirname \"$(which winnow)\")/Winnow.app/Contents/Resources/winnow_run_script.sh\n"; 536 | }; 537 | /* End PBXShellScriptBuildPhase section */ 538 | 539 | /* Begin PBXSourcesBuildPhase section */ 540 | 407A0CDC2707D0CD00F481C5 /* Sources */ = { 541 | isa = PBXSourcesBuildPhase; 542 | buildActionMask = 2147483647; 543 | files = ( 544 | 407A0CDE2707D0CD00F481C5 /* SceneDelegate.swift in Sources */, 545 | 407A0CE02707D0CD00F481C5 /* AppDelegate.swift in Sources */, 546 | 407A0CE12707D0CD00F481C5 /* PersistenceController.swift in Sources */, 547 | 407A0CEA2707D0CD00F481C5 /* CoreDataCloudKitShare.xcdatamodeld in Sources */, 548 | 40BF6728273B0B7400ED9D2D /* CoreDataCloudKitShareApp.swift in Sources */, 549 | ); 550 | runOnlyForDeploymentPostprocessing = 0; 551 | }; 552 | 407D7CE427024EBD00D2BA90 /* Sources */ = { 553 | isa = PBXSourcesBuildPhase; 554 | buildActionMask = 2147483647; 555 | files = ( 556 | 407D7D6B2704334400D2BA90 /* SceneDelegate.swift in Sources */, 557 | 40AE53F12719F98C00B978CA /* CoreDataHelper.swift in Sources */, 558 | 407D7D6D2704335A00D2BA90 /* AppDelegate.swift in Sources */, 559 | 40BF672F273B17A900ED9D2D /* PersistenceController+History.swift in Sources */, 560 | 408BD4DA2713B29900294A81 /* AddToExistingShareView.swift in Sources */, 561 | 4058316A2767DCDE0044E86D /* FullImageView.swift in Sources */, 562 | 4035B60E27150DA100F46D6B /* PhotoContextMenu.swift in Sources */, 563 | 4074428B270FB2B5009CABC7 /* PersistenceController+Share.swift in Sources */, 564 | 40D68E942719101A00FB9B78 /* PersistenceController+Deduplicate.swift in Sources */, 565 | 407D7D0427024F5D00D2BA90 /* PersistenceController.swift in Sources */, 566 | 407D7D3B2702A2A400D2BA90 /* PhotoPicker.swift in Sources */, 567 | 40744289270FAFB3009CABC7 /* ParticipantView.swift in Sources */, 568 | 403177D82742F4F70048F2DC /* SwiftUIHelper.swift in Sources */, 569 | 407D7CEE27024EBD00D2BA90 /* PhotoGridView.swift in Sources */, 570 | 408BD4DC2713F0F400294A81 /* ManagingSharesView.swift in Sources */, 571 | 40527E19270BC8FE00DB2901 /* PersistenceController+Rating.swift in Sources */, 572 | 407D7D612703880E00D2BA90 /* PersistenceController+Tag.swift in Sources */, 573 | 407D7CEC27024EBD00D2BA90 /* CoreDataCloudKitShareApp.swift in Sources */, 574 | 40527E17270BBE3400DB2901 /* RatingView.swift in Sources */, 575 | 407D7D5F2703878000D2BA90 /* PersistenceController+Photo.swift in Sources */, 576 | 407D7CF827024EBE00D2BA90 /* CoreDataCloudKitShare.xcdatamodeld in Sources */, 577 | 407D7D632703A27000D2BA90 /* PhotoGridItemView.swift in Sources */, 578 | 40C6211E2755AFB500015301 /* SharePickerView.swift in Sources */, 579 | 4026B868270A19390060F0F7 /* TaggingView.swift in Sources */, 580 | ); 581 | runOnlyForDeploymentPostprocessing = 0; 582 | }; 583 | 407D7D112702556800D2BA90 /* Sources */ = { 584 | isa = PBXSourcesBuildPhase; 585 | buildActionMask = 2147483647; 586 | files = ( 587 | 40B6201F273AFD3400B27D3D /* CoreDataHelper.swift in Sources */, 588 | 40B62020273AFD3700B27D3D /* PersistenceController.swift in Sources */, 589 | 40BF672E273B17A500ED9D2D /* PersistenceController+History.swift in Sources */, 590 | 403177DB27430E340048F2DC /* ParticipantView.swift in Sources */, 591 | 403177DA27430A800048F2DC /* PhotoGridView.swift in Sources */, 592 | 403177DD274310B50048F2DC /* ManagingSharesView.swift in Sources */, 593 | 40B62024273AFD9900B27D3D /* PersistenceController+Tag.swift in Sources */, 594 | 40B62021273AFD6700B27D3D /* CoreDataCloudKitShare.xcdatamodeld in Sources */, 595 | 403177DC274310690048F2DC /* AddToExistingShareView.swift in Sources */, 596 | 40B62027273AFDC600B27D3D /* PersistenceController+Photo.swift in Sources */, 597 | 403177DE27432CE40048F2DC /* PhotoContextMenu.swift in Sources */, 598 | 401C06ED276E9D790024D92C /* ExtensionDelegate.swift in Sources */, 599 | 403177D92742F4F70048F2DC /* SwiftUIHelper.swift in Sources */, 600 | 403177D42742DB270048F2DC /* TaggingView.swift in Sources */, 601 | 40B62022273AFD9300B27D3D /* PersistenceController+Deduplicate.swift in Sources */, 602 | 4058316B2767F0ED0044E86D /* FullImageView.swift in Sources */, 603 | 407D7D1B2702556800D2BA90 /* CoreDataCloudKitShareApp.swift in Sources */, 604 | 40BACC0B2740646E00F12CBD /* PersistenceController+Share.swift in Sources */, 605 | 40C6211F2755AFB500015301 /* SharePickerView.swift in Sources */, 606 | 403177D62742F4B40048F2DC /* PhotoGridItemView.swift in Sources */, 607 | 40B62023273AFD9700B27D3D /* PersistenceController+Rating.swift in Sources */, 608 | 403177D32742D5790048F2DC /* RatingView.swift in Sources */, 609 | ); 610 | runOnlyForDeploymentPostprocessing = 0; 611 | }; 612 | /* End PBXSourcesBuildPhase section */ 613 | 614 | /* Begin PBXTargetDependency section */ 615 | 407D7D182702556800D2BA90 /* PBXTargetDependency */ = { 616 | isa = PBXTargetDependency; 617 | target = 407D7D142702556800D2BA90 /* CoreDataCloudKitShareOnWatch WatchKit Extension */; 618 | targetProxy = 407D7D172702556800D2BA90 /* PBXContainerItemProxy */; 619 | }; 620 | 407D7D2C2702556900D2BA90 /* PBXTargetDependency */ = { 621 | isa = PBXTargetDependency; 622 | target = 407D7D0C2702556600D2BA90 /* CoreDataCloudKitShareOnWatch */; 623 | targetProxy = 407D7D2B2702556900D2BA90 /* PBXContainerItemProxy */; 624 | }; 625 | /* End PBXTargetDependency section */ 626 | 627 | /* Begin XCBuildConfiguration section */ 628 | 407A0CF42707D0CD00F481C5 /* Debug */ = { 629 | isa = XCBuildConfiguration; 630 | buildSettings = { 631 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 632 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 633 | CODE_SIGN_ENTITLEMENTS = CoreDataCloudKitShare/CoreDataCloudKitShare.entitlements; 634 | CODE_SIGN_STYLE = Automatic; 635 | CURRENT_PROJECT_VERSION = 1; 636 | DEVELOPMENT_ASSET_PATHS = "\"CoreDataCloudKitShare/Preview Content\""; 637 | DEVELOPMENT_TEAM = ""; 638 | ENABLE_PREVIEWS = YES; 639 | GENERATE_INFOPLIST_FILE = YES; 640 | INFOPLIST_FILE = "CoreDataCloudKitShare/InitializeCloudKitSchema-Info.plist"; 641 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 642 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 643 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 644 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 645 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 646 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 647 | LD_RUNPATH_SEARCH_PATHS = ( 648 | "$(inherited)", 649 | "@executable_path/Frameworks", 650 | ); 651 | MARKETING_VERSION = 1.0; 652 | PRODUCT_BUNDLE_IDENTIFIER = "com.example.ziqiao-samplecode.CoreDataCloudKitShare"; 653 | PRODUCT_NAME = "$(TARGET_NAME)"; 654 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG InitializeCloudKitSchema"; 655 | SWIFT_EMIT_LOC_STRINGS = YES; 656 | SWIFT_VERSION = 5.0; 657 | TARGETED_DEVICE_FAMILY = "1,2"; 658 | }; 659 | name = Debug; 660 | }; 661 | 407A0CF52707D0CD00F481C5 /* Release */ = { 662 | isa = XCBuildConfiguration; 663 | buildSettings = { 664 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 665 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 666 | CODE_SIGN_ENTITLEMENTS = CoreDataCloudKitShare/CoreDataCloudKitShare.entitlements; 667 | CODE_SIGN_STYLE = Automatic; 668 | CURRENT_PROJECT_VERSION = 1; 669 | DEVELOPMENT_ASSET_PATHS = "\"CoreDataCloudKitShare/Preview Content\""; 670 | DEVELOPMENT_TEAM = ""; 671 | ENABLE_PREVIEWS = YES; 672 | GENERATE_INFOPLIST_FILE = YES; 673 | INFOPLIST_FILE = "CoreDataCloudKitShare/InitializeCloudKitSchema-Info.plist"; 674 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 675 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 676 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 677 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 678 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 679 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 680 | LD_RUNPATH_SEARCH_PATHS = ( 681 | "$(inherited)", 682 | "@executable_path/Frameworks", 683 | ); 684 | MARKETING_VERSION = 1.0; 685 | PRODUCT_BUNDLE_IDENTIFIER = "com.example.ziqiao-samplecode.CoreDataCloudKitShare"; 686 | PRODUCT_NAME = "$(TARGET_NAME)"; 687 | SWIFT_EMIT_LOC_STRINGS = YES; 688 | SWIFT_VERSION = 5.0; 689 | TARGETED_DEVICE_FAMILY = "1,2"; 690 | }; 691 | name = Release; 692 | }; 693 | 407D7CF927024EBE00D2BA90 /* Debug */ = { 694 | isa = XCBuildConfiguration; 695 | buildSettings = { 696 | ALWAYS_SEARCH_USER_PATHS = NO; 697 | CLANG_ANALYZER_NONNULL = YES; 698 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 699 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 700 | CLANG_CXX_LIBRARY = "libc++"; 701 | CLANG_ENABLE_MODULES = YES; 702 | CLANG_ENABLE_OBJC_ARC = YES; 703 | CLANG_ENABLE_OBJC_WEAK = YES; 704 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 705 | CLANG_WARN_BOOL_CONVERSION = YES; 706 | CLANG_WARN_COMMA = YES; 707 | CLANG_WARN_CONSTANT_CONVERSION = YES; 708 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 709 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 710 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 711 | CLANG_WARN_EMPTY_BODY = YES; 712 | CLANG_WARN_ENUM_CONVERSION = YES; 713 | CLANG_WARN_INFINITE_RECURSION = YES; 714 | CLANG_WARN_INT_CONVERSION = YES; 715 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 716 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 717 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 718 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 719 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 720 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 721 | CLANG_WARN_STRICT_PROTOTYPES = YES; 722 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 723 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 724 | CLANG_WARN_UNREACHABLE_CODE = YES; 725 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 726 | COPY_PHASE_STRIP = NO; 727 | DEBUG_INFORMATION_FORMAT = dwarf; 728 | ENABLE_STRICT_OBJC_MSGSEND = YES; 729 | ENABLE_TESTABILITY = YES; 730 | GCC_C_LANGUAGE_STANDARD = gnu11; 731 | GCC_DYNAMIC_NO_PIC = NO; 732 | GCC_NO_COMMON_BLOCKS = YES; 733 | GCC_OPTIMIZATION_LEVEL = 0; 734 | GCC_PREPROCESSOR_DEFINITIONS = ( 735 | "DEBUG=1", 736 | "$(inherited)", 737 | ); 738 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 739 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 740 | GCC_WARN_UNDECLARED_SELECTOR = YES; 741 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 742 | GCC_WARN_UNUSED_FUNCTION = YES; 743 | GCC_WARN_UNUSED_VARIABLE = YES; 744 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 745 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 746 | MTL_FAST_MATH = YES; 747 | ONLY_ACTIVE_ARCH = YES; 748 | SDKROOT = iphoneos; 749 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 750 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 751 | }; 752 | name = Debug; 753 | }; 754 | 407D7CFA27024EBE00D2BA90 /* Release */ = { 755 | isa = XCBuildConfiguration; 756 | buildSettings = { 757 | ALWAYS_SEARCH_USER_PATHS = NO; 758 | CLANG_ANALYZER_NONNULL = YES; 759 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 760 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 761 | CLANG_CXX_LIBRARY = "libc++"; 762 | CLANG_ENABLE_MODULES = YES; 763 | CLANG_ENABLE_OBJC_ARC = YES; 764 | CLANG_ENABLE_OBJC_WEAK = YES; 765 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 766 | CLANG_WARN_BOOL_CONVERSION = YES; 767 | CLANG_WARN_COMMA = YES; 768 | CLANG_WARN_CONSTANT_CONVERSION = YES; 769 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 770 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 771 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 772 | CLANG_WARN_EMPTY_BODY = YES; 773 | CLANG_WARN_ENUM_CONVERSION = YES; 774 | CLANG_WARN_INFINITE_RECURSION = YES; 775 | CLANG_WARN_INT_CONVERSION = YES; 776 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 777 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 778 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 779 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 780 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 781 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 782 | CLANG_WARN_STRICT_PROTOTYPES = YES; 783 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 784 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 785 | CLANG_WARN_UNREACHABLE_CODE = YES; 786 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 787 | COPY_PHASE_STRIP = NO; 788 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 789 | ENABLE_NS_ASSERTIONS = NO; 790 | ENABLE_STRICT_OBJC_MSGSEND = YES; 791 | GCC_C_LANGUAGE_STANDARD = gnu11; 792 | GCC_NO_COMMON_BLOCKS = YES; 793 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 794 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 795 | GCC_WARN_UNDECLARED_SELECTOR = YES; 796 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 797 | GCC_WARN_UNUSED_FUNCTION = YES; 798 | GCC_WARN_UNUSED_VARIABLE = YES; 799 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 800 | MTL_ENABLE_DEBUG_INFO = NO; 801 | MTL_FAST_MATH = YES; 802 | SDKROOT = iphoneos; 803 | SWIFT_COMPILATION_MODE = wholemodule; 804 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 805 | VALIDATE_PRODUCT = YES; 806 | }; 807 | name = Release; 808 | }; 809 | 407D7CFC27024EBE00D2BA90 /* Debug */ = { 810 | isa = XCBuildConfiguration; 811 | buildSettings = { 812 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 813 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 814 | CODE_SIGN_ENTITLEMENTS = CoreDataCloudKitShare/CoreDataCloudKitShare.entitlements; 815 | CODE_SIGN_STYLE = Automatic; 816 | CURRENT_PROJECT_VERSION = 1; 817 | DEVELOPMENT_ASSET_PATHS = "\"CoreDataCloudKitShare/Preview Content\""; 818 | DEVELOPMENT_TEAM = ""; 819 | ENABLE_PREVIEWS = YES; 820 | GENERATE_INFOPLIST_FILE = YES; 821 | INFOPLIST_FILE = CoreDataCloudKitShare/Info.plist; 822 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 823 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 824 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 825 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 826 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 827 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 828 | LD_RUNPATH_SEARCH_PATHS = ( 829 | "$(inherited)", 830 | "@executable_path/Frameworks", 831 | ); 832 | MARKETING_VERSION = 1.0; 833 | PRODUCT_BUNDLE_IDENTIFIER = "com.example.ziqiao-samplecode.CoreDataCloudKitShare"; 834 | PRODUCT_NAME = "$(TARGET_NAME)"; 835 | SWIFT_EMIT_LOC_STRINGS = YES; 836 | SWIFT_VERSION = 5.0; 837 | TARGETED_DEVICE_FAMILY = "1,2"; 838 | }; 839 | name = Debug; 840 | }; 841 | 407D7CFD27024EBE00D2BA90 /* Release */ = { 842 | isa = XCBuildConfiguration; 843 | buildSettings = { 844 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 845 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 846 | CODE_SIGN_ENTITLEMENTS = CoreDataCloudKitShare/CoreDataCloudKitShare.entitlements; 847 | CODE_SIGN_STYLE = Automatic; 848 | CURRENT_PROJECT_VERSION = 1; 849 | DEVELOPMENT_ASSET_PATHS = "\"CoreDataCloudKitShare/Preview Content\""; 850 | DEVELOPMENT_TEAM = ""; 851 | ENABLE_PREVIEWS = YES; 852 | GENERATE_INFOPLIST_FILE = YES; 853 | INFOPLIST_FILE = CoreDataCloudKitShare/Info.plist; 854 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 855 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 856 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 857 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 858 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 859 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 860 | LD_RUNPATH_SEARCH_PATHS = ( 861 | "$(inherited)", 862 | "@executable_path/Frameworks", 863 | ); 864 | MARKETING_VERSION = 1.0; 865 | PRODUCT_BUNDLE_IDENTIFIER = "com.example.ziqiao-samplecode.CoreDataCloudKitShare"; 866 | PRODUCT_NAME = "$(TARGET_NAME)"; 867 | SWIFT_EMIT_LOC_STRINGS = YES; 868 | SWIFT_VERSION = 5.0; 869 | TARGETED_DEVICE_FAMILY = "1,2"; 870 | }; 871 | name = Release; 872 | }; 873 | 407D7D2F2702556900D2BA90 /* Debug */ = { 874 | isa = XCBuildConfiguration; 875 | buildSettings = { 876 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 877 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 878 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 879 | CODE_SIGN_STYLE = Automatic; 880 | CURRENT_PROJECT_VERSION = 1; 881 | DEVELOPMENT_TEAM = ""; 882 | GENERATE_INFOPLIST_FILE = YES; 883 | IBSC_MODULE = CoreDataCloudKitShareOnWatch_WatchKit_Extension; 884 | INFOPLIST_FILE = CoreDataCloudKitShareOnWatch/Info.plist; 885 | INFOPLIST_KEY_CFBundleDisplayName = CoreDataCloudKitShareOnWatch; 886 | INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; 887 | INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "com.example.ziqiao-samplecode.CoreDataCloudKitShare"; 888 | MARKETING_VERSION = 1.0; 889 | PRODUCT_BUNDLE_IDENTIFIER = "com.example.ziqiao-samplecode.CoreDataCloudKitShare.watchkitapp"; 890 | PRODUCT_NAME = "$(TARGET_NAME)"; 891 | SDKROOT = watchos; 892 | SKIP_INSTALL = YES; 893 | SWIFT_EMIT_LOC_STRINGS = YES; 894 | SWIFT_VERSION = 5.0; 895 | TARGETED_DEVICE_FAMILY = 4; 896 | WATCHOS_DEPLOYMENT_TARGET = 8.0; 897 | }; 898 | name = Debug; 899 | }; 900 | 407D7D302702556900D2BA90 /* Release */ = { 901 | isa = XCBuildConfiguration; 902 | buildSettings = { 903 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 904 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 905 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 906 | CODE_SIGN_STYLE = Automatic; 907 | CURRENT_PROJECT_VERSION = 1; 908 | DEVELOPMENT_TEAM = ""; 909 | GENERATE_INFOPLIST_FILE = YES; 910 | IBSC_MODULE = CoreDataCloudKitShareOnWatch_WatchKit_Extension; 911 | INFOPLIST_FILE = CoreDataCloudKitShareOnWatch/Info.plist; 912 | INFOPLIST_KEY_CFBundleDisplayName = CoreDataCloudKitShareOnWatch; 913 | INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; 914 | INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "com.example.ziqiao-samplecode.CoreDataCloudKitShare"; 915 | MARKETING_VERSION = 1.0; 916 | PRODUCT_BUNDLE_IDENTIFIER = "com.example.ziqiao-samplecode.CoreDataCloudKitShare.watchkitapp"; 917 | PRODUCT_NAME = "$(TARGET_NAME)"; 918 | SDKROOT = watchos; 919 | SKIP_INSTALL = YES; 920 | SWIFT_EMIT_LOC_STRINGS = YES; 921 | SWIFT_VERSION = 5.0; 922 | TARGETED_DEVICE_FAMILY = 4; 923 | WATCHOS_DEPLOYMENT_TARGET = 8.0; 924 | }; 925 | name = Release; 926 | }; 927 | 407D7D322702556900D2BA90 /* Debug */ = { 928 | isa = XCBuildConfiguration; 929 | buildSettings = { 930 | ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; 931 | CODE_SIGN_ENTITLEMENTS = "CoreDataCloudKitShareOnWatch WatchKit Extension/CoreDataCloudKitShareOnWatch WatchKit Extension.entitlements"; 932 | CODE_SIGN_STYLE = Automatic; 933 | CURRENT_PROJECT_VERSION = 1; 934 | DEVELOPMENT_ASSET_PATHS = "\"CoreDataCloudKitShareOnWatch WatchKit Extension/Preview Content\""; 935 | DEVELOPMENT_TEAM = ""; 936 | ENABLE_PREVIEWS = YES; 937 | GENERATE_INFOPLIST_FILE = YES; 938 | INFOPLIST_FILE = "CoreDataCloudKitShareOnWatch WatchKit Extension/Info.plist"; 939 | INFOPLIST_KEY_CFBundleDisplayName = "CoreDataCloudKitShareOnWatch WatchKit Extension"; 940 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 941 | LD_RUNPATH_SEARCH_PATHS = ( 942 | "$(inherited)", 943 | "@executable_path/Frameworks", 944 | "@executable_path/../../Frameworks", 945 | ); 946 | MARKETING_VERSION = 1.0; 947 | PRODUCT_BUNDLE_IDENTIFIER = "com.example.ziqiao-samplecode.CoreDataCloudKitShare.watchkitapp.watchkitextension"; 948 | PRODUCT_NAME = "${TARGET_NAME}"; 949 | SDKROOT = watchos; 950 | SKIP_INSTALL = YES; 951 | SWIFT_EMIT_LOC_STRINGS = YES; 952 | SWIFT_VERSION = 5.0; 953 | TARGETED_DEVICE_FAMILY = 4; 954 | WATCHOS_DEPLOYMENT_TARGET = 8.0; 955 | }; 956 | name = Debug; 957 | }; 958 | 407D7D332702556900D2BA90 /* Release */ = { 959 | isa = XCBuildConfiguration; 960 | buildSettings = { 961 | ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; 962 | CODE_SIGN_ENTITLEMENTS = "CoreDataCloudKitShareOnWatch WatchKit Extension/CoreDataCloudKitShareOnWatch WatchKit Extension.entitlements"; 963 | CODE_SIGN_STYLE = Automatic; 964 | CURRENT_PROJECT_VERSION = 1; 965 | DEVELOPMENT_ASSET_PATHS = "\"CoreDataCloudKitShareOnWatch WatchKit Extension/Preview Content\""; 966 | DEVELOPMENT_TEAM = ""; 967 | ENABLE_PREVIEWS = YES; 968 | GENERATE_INFOPLIST_FILE = YES; 969 | INFOPLIST_FILE = "CoreDataCloudKitShareOnWatch WatchKit Extension/Info.plist"; 970 | INFOPLIST_KEY_CFBundleDisplayName = "CoreDataCloudKitShareOnWatch WatchKit Extension"; 971 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 972 | LD_RUNPATH_SEARCH_PATHS = ( 973 | "$(inherited)", 974 | "@executable_path/Frameworks", 975 | "@executable_path/../../Frameworks", 976 | ); 977 | MARKETING_VERSION = 1.0; 978 | PRODUCT_BUNDLE_IDENTIFIER = "com.example.ziqiao-samplecode.CoreDataCloudKitShare.watchkitapp.watchkitextension"; 979 | PRODUCT_NAME = "${TARGET_NAME}"; 980 | SDKROOT = watchos; 981 | SKIP_INSTALL = YES; 982 | SWIFT_EMIT_LOC_STRINGS = YES; 983 | SWIFT_VERSION = 5.0; 984 | TARGETED_DEVICE_FAMILY = 4; 985 | WATCHOS_DEPLOYMENT_TARGET = 8.0; 986 | }; 987 | name = Release; 988 | }; 989 | /* End XCBuildConfiguration section */ 990 | 991 | /* Begin XCConfigurationList section */ 992 | 407A0CF32707D0CD00F481C5 /* Build configuration list for PBXNativeTarget "InitializeCloudKitSchema" */ = { 993 | isa = XCConfigurationList; 994 | buildConfigurations = ( 995 | 407A0CF42707D0CD00F481C5 /* Debug */, 996 | 407A0CF52707D0CD00F481C5 /* Release */, 997 | ); 998 | defaultConfigurationIsVisible = 0; 999 | defaultConfigurationName = Release; 1000 | }; 1001 | 407D7CE327024EBD00D2BA90 /* Build configuration list for PBXProject "CoreDataCloudKitShare" */ = { 1002 | isa = XCConfigurationList; 1003 | buildConfigurations = ( 1004 | 407D7CF927024EBE00D2BA90 /* Debug */, 1005 | 407D7CFA27024EBE00D2BA90 /* Release */, 1006 | ); 1007 | defaultConfigurationIsVisible = 0; 1008 | defaultConfigurationName = Release; 1009 | }; 1010 | 407D7CFB27024EBE00D2BA90 /* Build configuration list for PBXNativeTarget "CoreDataCloudKitShare" */ = { 1011 | isa = XCConfigurationList; 1012 | buildConfigurations = ( 1013 | 407D7CFC27024EBE00D2BA90 /* Debug */, 1014 | 407D7CFD27024EBE00D2BA90 /* Release */, 1015 | ); 1016 | defaultConfigurationIsVisible = 0; 1017 | defaultConfigurationName = Release; 1018 | }; 1019 | 407D7D342702556900D2BA90 /* Build configuration list for PBXNativeTarget "CoreDataCloudKitShareOnWatch WatchKit Extension" */ = { 1020 | isa = XCConfigurationList; 1021 | buildConfigurations = ( 1022 | 407D7D322702556900D2BA90 /* Debug */, 1023 | 407D7D332702556900D2BA90 /* Release */, 1024 | ); 1025 | defaultConfigurationIsVisible = 0; 1026 | defaultConfigurationName = Release; 1027 | }; 1028 | 407D7D352702556900D2BA90 /* Build configuration list for PBXNativeTarget "CoreDataCloudKitShareOnWatch" */ = { 1029 | isa = XCConfigurationList; 1030 | buildConfigurations = ( 1031 | 407D7D2F2702556900D2BA90 /* Debug */, 1032 | 407D7D302702556900D2BA90 /* Release */, 1033 | ); 1034 | defaultConfigurationIsVisible = 0; 1035 | defaultConfigurationName = Release; 1036 | }; 1037 | /* End XCConfigurationList section */ 1038 | 1039 | /* Begin XCVersionGroup section */ 1040 | 407D7CF627024EBE00D2BA90 /* CoreDataCloudKitShare.xcdatamodeld */ = { 1041 | isa = XCVersionGroup; 1042 | children = ( 1043 | 407D7CF727024EBE00D2BA90 /* CoreDataCloudKitShare.xcdatamodel */, 1044 | ); 1045 | currentVersion = 407D7CF727024EBE00D2BA90 /* CoreDataCloudKitShare.xcdatamodel */; 1046 | path = CoreDataCloudKitShare.xcdatamodeld; 1047 | sourceTree = ""; 1048 | versionGroupType = wrapper.xcdatamodel; 1049 | }; 1050 | /* End XCVersionGroup section */ 1051 | }; 1052 | rootObject = 407D7CE027024EBD00D2BA90 /* Project object */; 1053 | } 1054 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildSystemType 6 | Latest 7 | 8 | 9 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare.xcodeproj/xcshareddata/xcschemes/CoreDataCloudKitShare.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 57 | 58 | 61 | 62 | 65 | 66 | 67 | 68 | 74 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare.xcodeproj/xcshareddata/xcschemes/CoreDataCloudKitShareOnWatch.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 58 | 59 | 60 | 61 | 71 | 73 | 79 | 80 | 81 | 82 | 85 | 86 | 89 | 90 | 93 | 94 | 95 | 96 | 102 | 104 | 110 | 111 | 112 | 113 | 115 | 116 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare.xcodeproj/xcshareddata/xcschemes/InitializeCloudKitSchema.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 57 | 58 | 59 | 60 | 66 | 68 | 74 | 75 | 76 | 77 | 79 | 80 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delawaremathguy/CoreDataCloudKitShare/5a1894c60591c7005905f956a11a58eada1a92ec/CoreDataCloudKitShare/.DS_Store -------------------------------------------------------------------------------- /CoreDataCloudKitShare/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | The app delegate class. 5 | 6 | 7 | */ 8 | 9 | import UIKit 10 | import CoreData 11 | 12 | //@main 13 | class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | return true 16 | } 17 | 18 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, 19 | options: UIScene.ConnectionOptions) -> UISceneConfiguration { 20 | let configuration = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 21 | configuration.delegateClass = SceneDelegate.self 22 | return configuration 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare/Assets.xcassets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delawaremathguy/CoreDataCloudKitShare/5a1894c60591c7005905f956a11a58eada1a92ec/CoreDataCloudKitShare/Assets.xcassets/.DS_Store -------------------------------------------------------------------------------- /CoreDataCloudKitShare/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 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare/CoreDataCloudKitShare.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | iCloud.com.example.ziqiao-samplecode.CoreDataCloudKitShare 10 | 11 | com.apple.developer.icloud-services 12 | 13 | CloudKit 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare/CoreDataCloudKitShareApp.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | The SwiftUI app for iOS. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import CoreData 11 | 12 | @main 13 | struct CoreDataCloudKitShareApp: App { 14 | // swiftlint:disable weak_delegate 15 | @UIApplicationDelegateAdaptor var appDelegate: AppDelegate 16 | // swiftlint:enable weak_delegate 17 | private let persistentContainer = PersistenceController.shared.persistentContainer 18 | 19 | var body: some Scene { 20 | #if InitializeCloudKitSchema 21 | WindowGroup { 22 | Text("Initializing CloudKit Schema...").font(.title) 23 | Text("Stop after Xcode says 'no more requests to execute', " + 24 | "then check with CloudKit Console if the schema is created correctly.").padding() 25 | } 26 | #else 27 | WindowGroup { 28 | PhotoGridView() 29 | .environment(\.managedObjectContext, persistentContainer.viewContext) 30 | } 31 | #endif 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CKSharingSupported 6 | 7 | UIBackgroundModes 8 | 9 | remote-notification 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare/InitializeCloudKitSchema-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIBackgroundModes 6 | 7 | remote-notification 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare/PhotoPicker.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | A UIViewControllerRepresentable that wraps PHPickerViewController. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import PhotosUI 11 | import CoreData 12 | 13 | struct PhotoPicker: UIViewControllerRepresentable { 14 | @Binding var isPresented: ActiveSheet? 15 | let persistenceController = PersistenceController.shared 16 | 17 | func makeUIViewController(context: Context) -> PHPickerViewController { 18 | let configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) 19 | let controller = PHPickerViewController(configuration: configuration) 20 | controller.delegate = context.coordinator 21 | return controller 22 | } 23 | 24 | func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { 25 | } 26 | 27 | func makeCoordinator() -> PhotoPickerCoordinator { 28 | PhotoPickerCoordinator(photoPicker: self) 29 | } 30 | } 31 | 32 | /** 33 | The coordinator class that saves the picked image to the Core Data store. 34 | */ 35 | class PhotoPickerCoordinator: PHPickerViewControllerDelegate { 36 | private var photoPicker: PhotoPicker 37 | 38 | init(photoPicker: PhotoPicker) { 39 | self.photoPicker = photoPicker 40 | } 41 | 42 | func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { 43 | for result in results { 44 | result.itemProvider.loadObject(ofClass: UIImage.self) { (object, error) in 45 | guard let image = object as? UIImage else { 46 | print("Failed to load UIImage from the picker reuslt.") 47 | return 48 | } 49 | self.saveImage(image) 50 | } 51 | } 52 | // The system doesn’t automatically dismiss the picker so toggle isPresented to do that. 53 | photoPicker.isPresented = nil 54 | } 55 | 56 | private func saveImage(_ image: UIImage) { 57 | guard let imageData = image.jpegData(compressionQuality: 1) else { 58 | print("\(#function): Failed to retrieve JPG data and URL of the picked image.") 59 | return 60 | } 61 | guard let thumbnailData = thumbnail(with: imageData)?.jpegData(compressionQuality: 1) else { 62 | print("\(#function): Failed to create a thumbnail for the picked image.") 63 | return 64 | } 65 | let controller = photoPicker.persistenceController 66 | let taskContext = controller.persistentContainer.newTaskContext() 67 | taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy 68 | controller.addPhoto(photoData: imageData, thumbnailData: thumbnailData, context: taskContext) 69 | } 70 | 71 | private func thumbnail(with imageData: Data, pixelSize: Int = 120) -> UIImage? { 72 | let options = [kCGImageSourceCreateThumbnailWithTransform: true, 73 | kCGImageSourceCreateThumbnailFromImageAlways: true, 74 | kCGImageSourceThumbnailMaxPixelSize: pixelSize] as CFDictionary 75 | guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil) else { 76 | return nil 77 | } 78 | let imageReference = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options)! 79 | return UIImage(cgImage: imageReference) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CoreDataCloudKitShare/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | The scene delegate class. 5 | 6 | 7 | */ 8 | 9 | import UIKit 10 | import CloudKit 11 | 12 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 13 | var window: UIWindow? 14 | /** 15 | To be able to accept a share, add a CKSharingSupported entry in the info.plist file and set it to true. 16 | */ 17 | func windowScene(_ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) { 18 | let persistenceController = PersistenceController.shared 19 | let sharedStore = persistenceController.sharedPersistentStore 20 | let container = persistenceController.persistentContainer 21 | container.acceptShareInvitations(from: [cloudKitShareMetadata], into: sharedStore) { (_, error) in 22 | if let error = error { 23 | print("\(#function): Failed to accept share invitations: \(error)") 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delawaremathguy/CoreDataCloudKitShare/5a1894c60591c7005905f956a11a58eada1a92ec/CoreDataCloudKitShareOnWatch WatchKit Extension/.DS_Store -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Assets.xcassets/Complication.complicationset/Circular.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "watch", 5 | "scale" : "2x" 6 | }, 7 | { 8 | "idiom" : "watch", 9 | "scale" : "2x", 10 | "screen-width" : "<=145" 11 | }, 12 | { 13 | "idiom" : "watch", 14 | "scale" : "2x", 15 | "screen-width" : ">183" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "auto-scaling" : "auto" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Assets.xcassets/Complication.complicationset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "assets" : [ 3 | { 4 | "filename" : "Circular.imageset", 5 | "idiom" : "watch", 6 | "role" : "circular" 7 | }, 8 | { 9 | "filename" : "Extra Large.imageset", 10 | "idiom" : "watch", 11 | "role" : "extra-large" 12 | }, 13 | { 14 | "filename" : "Graphic Bezel.imageset", 15 | "idiom" : "watch", 16 | "role" : "graphic-bezel" 17 | }, 18 | { 19 | "filename" : "Graphic Circular.imageset", 20 | "idiom" : "watch", 21 | "role" : "graphic-circular" 22 | }, 23 | { 24 | "filename" : "Graphic Corner.imageset", 25 | "idiom" : "watch", 26 | "role" : "graphic-corner" 27 | }, 28 | { 29 | "filename" : "Graphic Extra Large.imageset", 30 | "idiom" : "watch", 31 | "role" : "graphic-extra-large" 32 | }, 33 | { 34 | "filename" : "Graphic Large Rectangular.imageset", 35 | "idiom" : "watch", 36 | "role" : "graphic-large-rectangular" 37 | }, 38 | { 39 | "filename" : "Modular.imageset", 40 | "idiom" : "watch", 41 | "role" : "modular" 42 | }, 43 | { 44 | "filename" : "Utilitarian.imageset", 45 | "idiom" : "watch", 46 | "role" : "utilitarian" 47 | } 48 | ], 49 | "info" : { 50 | "author" : "xcode", 51 | "version" : 1 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Assets.xcassets/Complication.complicationset/Extra Large.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "watch", 5 | "scale" : "2x" 6 | }, 7 | { 8 | "idiom" : "watch", 9 | "scale" : "2x", 10 | "screen-width" : "<=145" 11 | }, 12 | { 13 | "idiom" : "watch", 14 | "scale" : "2x", 15 | "screen-width" : ">183" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "auto-scaling" : "auto" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "watch", 5 | "scale" : "2x" 6 | }, 7 | { 8 | "idiom" : "watch", 9 | "scale" : "2x", 10 | "screen-width" : ">183" 11 | } 12 | ], 13 | "info" : { 14 | "author" : "xcode", 15 | "version" : 1 16 | }, 17 | "properties" : { 18 | "auto-scaling" : "auto" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "watch", 5 | "scale" : "2x" 6 | }, 7 | { 8 | "idiom" : "watch", 9 | "scale" : "2x", 10 | "screen-width" : ">183" 11 | } 12 | ], 13 | "info" : { 14 | "author" : "xcode", 15 | "version" : 1 16 | }, 17 | "properties" : { 18 | "auto-scaling" : "auto" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "watch", 5 | "scale" : "2x" 6 | }, 7 | { 8 | "idiom" : "watch", 9 | "scale" : "2x", 10 | "screen-width" : ">183" 11 | } 12 | ], 13 | "info" : { 14 | "author" : "xcode", 15 | "version" : 1 16 | }, 17 | "properties" : { 18 | "auto-scaling" : "auto" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Assets.xcassets/Complication.complicationset/Graphic Extra Large.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "watch", 5 | "scale" : "2x" 6 | }, 7 | { 8 | "idiom" : "watch", 9 | "scale" : "2x", 10 | "screen-width" : "<=145" 11 | }, 12 | { 13 | "idiom" : "watch", 14 | "scale" : "2x", 15 | "screen-width" : ">183" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "auto-scaling" : "auto" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Assets.xcassets/Complication.complicationset/Graphic Large Rectangular.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "watch", 5 | "scale" : "2x" 6 | }, 7 | { 8 | "idiom" : "watch", 9 | "scale" : "2x", 10 | "screen-width" : ">183" 11 | } 12 | ], 13 | "info" : { 14 | "author" : "xcode", 15 | "version" : 1 16 | }, 17 | "properties" : { 18 | "auto-scaling" : "auto" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "watch", 5 | "scale" : "2x" 6 | }, 7 | { 8 | "idiom" : "watch", 9 | "scale" : "2x", 10 | "screen-width" : "<=145" 11 | }, 12 | { 13 | "idiom" : "watch", 14 | "scale" : "2x", 15 | "screen-width" : ">183" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "auto-scaling" : "auto" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "watch", 5 | "scale" : "2x" 6 | }, 7 | { 8 | "idiom" : "watch", 9 | "scale" : "2x", 10 | "screen-width" : "<=145" 11 | }, 12 | { 13 | "idiom" : "watch", 14 | "scale" : "2x", 15 | "screen-width" : ">183" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "auto-scaling" : "auto" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/CoreDataCloudKitShareApp.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | The SwiftUI app for watchOS. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | 11 | @main 12 | struct CoreDataCloudKitShareApp: App { 13 | @WKExtensionDelegateAdaptor var delegateOfExtension: ExtensionDelegate 14 | 15 | let persistenceController = PersistenceController.shared 16 | 17 | @SceneBuilder var body: some Scene { 18 | WindowGroup { 19 | PhotoGridView() 20 | .environment(\.managedObjectContext, persistenceController.persistentContainer.viewContext) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/CoreDataCloudKitShareOnWatch WatchKit Extension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | iCloud.com.example.ziqiao-samplecode.CoreDataCloudKitShare 10 | 11 | com.apple.developer.icloud-services 12 | 13 | CloudKit 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/ExtensionDelegate.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | The WatchKit extension delegate class. 5 | 6 | 7 | */ 8 | 9 | import WatchKit 10 | import CloudKit 11 | 12 | class ExtensionDelegate: NSObject, WKExtensionDelegate { 13 | /** 14 | To be able to accept a share, add a CKSharingSupported entry in the info.plist file of the WatchKit app and set it to true. 15 | */ 16 | func userDidAcceptCloudKitShare(with cloudKitShareMetadata: CKShare.Metadata) { 17 | let persistenceController = PersistenceController.shared 18 | let sharedStore = persistenceController.sharedPersistentStore 19 | let container = persistenceController.persistentContainer 20 | container.acceptShareInvitations(from: [cloudKitShareMetadata], into: sharedStore) { (_, error) in 21 | if let error = error { 22 | print("\(#function): Failed to accept share invitations: \(error)") 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | NSExtension 8 | 9 | NSExtensionAttributes 10 | 11 | WKAppBundleIdentifier 12 | com.example.ziqiao-samplecode.CoreDataCloudKitShare.watchkitapp 13 | 14 | NSExtensionPointIdentifier 15 | com.apple.watchkit 16 | 17 | UIBackgroundModes 18 | 19 | remote-notification 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch WatchKit Extension/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delawaremathguy/CoreDataCloudKitShare/5a1894c60591c7005905f956a11a58eada1a92ec/CoreDataCloudKitShareOnWatch/.DS_Store -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch/Assets.xcassets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delawaremathguy/CoreDataCloudKitShare/5a1894c60591c7005905f956a11a58eada1a92ec/CoreDataCloudKitShareOnWatch/Assets.xcassets/.DS_Store -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch/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 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "watch", 5 | "role" : "notificationCenter", 6 | "scale" : "2x", 7 | "size" : "24x24", 8 | "subtype" : "38mm" 9 | }, 10 | { 11 | "idiom" : "watch", 12 | "role" : "notificationCenter", 13 | "scale" : "2x", 14 | "size" : "27.5x27.5", 15 | "subtype" : "42mm" 16 | }, 17 | { 18 | "idiom" : "watch", 19 | "role" : "companionSettings", 20 | "scale" : "2x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "watch", 25 | "role" : "companionSettings", 26 | "scale" : "3x", 27 | "size" : "29x29" 28 | }, 29 | { 30 | "idiom" : "watch", 31 | "role" : "notificationCenter", 32 | "scale" : "2x", 33 | "size" : "33x33", 34 | "subtype" : "45mm" 35 | }, 36 | { 37 | "idiom" : "watch", 38 | "role" : "appLauncher", 39 | "scale" : "2x", 40 | "size" : "40x40", 41 | "subtype" : "38mm" 42 | }, 43 | { 44 | "idiom" : "watch", 45 | "role" : "appLauncher", 46 | "scale" : "2x", 47 | "size" : "44x44", 48 | "subtype" : "40mm" 49 | }, 50 | { 51 | "idiom" : "watch", 52 | "role" : "appLauncher", 53 | "scale" : "2x", 54 | "size" : "46x46", 55 | "subtype" : "41mm" 56 | }, 57 | { 58 | "idiom" : "watch", 59 | "role" : "appLauncher", 60 | "scale" : "2x", 61 | "size" : "50x50", 62 | "subtype" : "44mm" 63 | }, 64 | { 65 | "idiom" : "watch", 66 | "role" : "appLauncher", 67 | "scale" : "2x", 68 | "size" : "51x51", 69 | "subtype" : "45mm" 70 | }, 71 | { 72 | "idiom" : "watch", 73 | "role" : "quickLook", 74 | "scale" : "2x", 75 | "size" : "86x86", 76 | "subtype" : "38mm" 77 | }, 78 | { 79 | "idiom" : "watch", 80 | "role" : "quickLook", 81 | "scale" : "2x", 82 | "size" : "98x98", 83 | "subtype" : "42mm" 84 | }, 85 | { 86 | "idiom" : "watch", 87 | "role" : "quickLook", 88 | "scale" : "2x", 89 | "size" : "108x108", 90 | "subtype" : "44mm" 91 | }, 92 | { 93 | "idiom" : "watch", 94 | "role" : "quickLook", 95 | "scale" : "2x", 96 | "size" : "117x117", 97 | "subtype" : "45mm" 98 | }, 99 | { 100 | "idiom" : "watch-marketing", 101 | "scale" : "1x", 102 | "size" : "1024x1024" 103 | } 104 | ], 105 | "info" : { 106 | "author" : "xcode", 107 | "version" : 1 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CoreDataCloudKitShareOnWatch/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CKSharingSupported 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 ziqiaochen 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 | -------------------------------------------------------------------------------- /Persistence/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delawaremathguy/CoreDataCloudKitShare/5a1894c60591c7005905f956a11a58eada1a92ec/Persistence/.DS_Store -------------------------------------------------------------------------------- /Persistence/CoreDataCloudKitShare.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | CoreDataCloudKitShare.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /Persistence/CoreDataCloudKitShare.xcdatamodeld/CoreDataCloudKitShare.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /Persistence/CoreDataHelper.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | Extensions that add convenience methods to Core Data. 5 | 6 | 7 | */ 8 | 9 | import CoreData 10 | import CloudKit 11 | 12 | extension NSPersistentStore { 13 | func contains(manageObject: NSManagedObject) -> Bool { 14 | let fetchRequest = NSFetchRequest(entityName: manageObject.entity.name!) 15 | fetchRequest.predicate = NSPredicate(format: "self == %@", manageObject) 16 | fetchRequest.affectedStores = [self] 17 | 18 | if let context = manageObject.managedObjectContext, 19 | let result = try? context.count(for: fetchRequest), result > 0 { 20 | return true 21 | } 22 | return false 23 | } 24 | } 25 | 26 | extension NSManagedObject { 27 | var persistentStore: NSPersistentStore? { 28 | let persistenceController = PersistenceController.shared 29 | if persistenceController.sharedPersistentStore.contains(manageObject: self) { 30 | return persistenceController.sharedPersistentStore 31 | } else if persistenceController.privatePersistentStore.contains(manageObject: self) { 32 | return persistenceController.privatePersistentStore 33 | } 34 | return nil 35 | } 36 | } 37 | 38 | extension NSManagedObjectContext { 39 | /** 40 | Contextual information for handling error that happens when saving a managed object context. 41 | */ 42 | enum ContextualInfoForSaving: String { 43 | case addPhoto, deletePhoto 44 | case toggleTagging, deleteTag, addTag 45 | case addRating, deleteRating 46 | case sheetOnDismiss 47 | case deduplicateAndWait 48 | } 49 | /** 50 | Save a context and handle the save error. This sample simply prints the error message. Real apps should 51 | consider comprehensive error handling based on the contextual information. 52 | */ 53 | func save(with contextualInfo: ContextualInfoForSaving) { 54 | if hasChanges { 55 | do { 56 | try save() 57 | } catch { 58 | print("\(#function): Failed to save Core Data context for \(contextualInfo.rawValue): \(error)") 59 | } 60 | } 61 | } 62 | } 63 | 64 | /** 65 | A convenience method for creating background contexts that specify the app as their transaction author. 66 | */ 67 | extension NSPersistentCloudKitContainer { 68 | func newTaskContext() -> NSManagedObjectContext { 69 | let context = newBackgroundContext() 70 | context.transactionAuthor = TransactionAuthor.app 71 | return context 72 | } 73 | 74 | /** 75 | Fetch and return shares in the persistent stores. 76 | */ 77 | func fetchShares(in persistentStores: [NSPersistentStore]) throws -> [CKShare] { 78 | var results = [CKShare]() 79 | for persistentStore in persistentStores { 80 | do { 81 | let shares = try fetchShares(in: persistentStore) 82 | results += shares 83 | } catch let error { 84 | print("Failed to fetch shares in \(persistentStore).") 85 | throw error 86 | } 87 | } 88 | return results 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /Persistence/PersistenceController+Deduplicate.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | An extension that wraps the methods related to deduplicating tags. 5 | 6 | 7 | */ 8 | 9 | import CoreData 10 | import CloudKit 11 | 12 | // MARK: - Deduplicate tags 13 | // 14 | extension PersistenceController { 15 | /** 16 | Deduplicate tags that have a same name and are in the same CloudKit record zone, one tag at a time, on the historyQueue. 17 | All peers should eventually reach the same result with no coordination or communication. 18 | */ 19 | 20 | //#-code-listing(deduplicateAndWait) 21 | func deduplicateAndWait(tagObjectIDs: [NSManagedObjectID]) 22 | //#-end-code-listing 23 | { 24 | /** 25 | Make any store changes on a background context with the transaction author name of this app. 26 | Use performAndWait to serialize the steps. historyQueue runs in the background so this won’t block the main queue. 27 | */ 28 | let taskContext = persistentContainer.newTaskContext() 29 | taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy 30 | taskContext.performAndWait { 31 | tagObjectIDs.forEach { tagObjectID in 32 | deduplicate(tagObjectID: tagObjectID, performingContext: taskContext) 33 | } 34 | taskContext.save(with: .deduplicateAndWait) 35 | } 36 | } 37 | 38 | /** 39 | Deduplicate one single tag. 40 | */ 41 | private func deduplicate(tagObjectID: NSManagedObjectID, performingContext: NSManagedObjectContext) { 42 | /** 43 | tag.name can be nil when the app inserts a tag and then ( before processing the insertion ) delete it. 44 | In that case, silently ignore the deleted tag. 45 | */ 46 | guard let tag = performingContext.object(with: tagObjectID) as? Tag, 47 | let tagName = tag.name else { 48 | print("\(#function): Ignore a tag that was deleted: \(tagObjectID)") 49 | return 50 | } 51 | /** 52 | Fetch all tags with the same name, sorted by uuid, and return if there are no duplicates. 53 | */ 54 | let fetchRequest: NSFetchRequest = Tag.fetchRequest() 55 | fetchRequest.sortDescriptors = [NSSortDescriptor(key: Tag.Schema.uuid.rawValue, ascending: true)] 56 | fetchRequest.predicate = NSPredicate(format: "\(Tag.Schema.name.rawValue) == %@", tagName) 57 | guard var duplicatedTags = try? performingContext.fetch(fetchRequest), duplicatedTags.count > 1 else { 58 | return 59 | } 60 | 61 | /** 62 | Filter out the tags that are not in the same CloudKit record zone. 63 | Only tags that have the same name and are in the same record zone are duplicates. 64 | The tag zone ID can be nil, which means it isn't a shared tag. The filter rule is still valid in that case. 65 | */ 66 | let tagZoneID = persistentContainer.recordID(for: tag.objectID)?.zoneID 67 | duplicatedTags = duplicatedTags.filter { 68 | self.persistentContainer.recordID(for: $0.objectID)?.zoneID == tagZoneID 69 | } 70 | 71 | guard duplicatedTags.count > 1 else { 72 | return 73 | } 74 | /** 75 | Pick the first tag as the winner. 76 | */ 77 | print("\(#function): Deduplicating tag with name: \(tagName), count: \(duplicatedTags.count)") 78 | let winner = duplicatedTags.first! 79 | duplicatedTags.removeFirst() 80 | remove(duplicatedTags: duplicatedTags, winner: winner, performingContext: performingContext) 81 | } 82 | 83 | /** 84 | Remove duplicate tags from their respective photos, replacing them with the winner. 85 | */ 86 | private func remove(duplicatedTags: [Tag], winner: Tag, performingContext: NSManagedObjectContext) { 87 | duplicatedTags.forEach { tag in 88 | if let photoSet = tag.photos { 89 | for case let photo as Photo in photoSet { 90 | photo.removeFromTags(tag) 91 | photo.addToTags(winner) 92 | } 93 | } 94 | performingContext.delete(tag) 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Persistence/PersistenceController+History.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | Extensions that wraps the methods related to persistence history processing. 5 | 6 | 7 | */ 8 | 9 | import CoreData 10 | import CloudKit 11 | 12 | // MARK: - Notification handlers that trigger history processing. 13 | // 14 | extension PersistenceController { 15 | /** 16 | Handle .NSPersistentStoreRemoteChange notifications. 17 | Process persistent history to merge relevant changes to the context, and deduplicate the tags if necessary. 18 | */ 19 | @objc 20 | func storeRemoteChange(_ notification: Notification) { 21 | guard let storeUUID = notification.userInfo?[NSStoreUUIDKey] as? String, 22 | [privatePersistentStore.identifier, sharedPersistentStore.identifier].contains(storeUUID) else { 23 | print("\(#function): Ignore a store remote Change notification because of no valid storeUUID.") 24 | return 25 | } 26 | processHistoryAsynchronously(storeUUID: storeUUID) 27 | } 28 | 29 | /** 30 | Handle the container's event changed notifications (NSPersistentCloudKitContainer.eventChangedNotification). 31 | */ 32 | @objc 33 | func containerEventChanged(_ notification: Notification) { 34 | guard let value = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey], 35 | let event = value as? NSPersistentCloudKitContainer.Event else { 36 | print("\(#function): Failed to retrieve the container event from notification.userInfo.") 37 | return 38 | } 39 | if event.error != nil { 40 | print("\(#function): Received a persistent CloudKit container event changed notification.\n\(event)") 41 | } 42 | } 43 | } 44 | 45 | // MARK: - Process persistent historty asynchronously 46 | // 47 | extension PersistenceController { 48 | /** 49 | Process persistent history, posting any relevant transactions to the current view. 50 | This method processes the new history since the last history token, and is simply a fetch if there is no new history. 51 | */ 52 | private func processHistoryAsynchronously(storeUUID: String) { 53 | historyQueue.addOperation { 54 | let taskContext = self.persistentContainer.newTaskContext() 55 | taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy 56 | taskContext.performAndWait { 57 | self.performHistoryProcessing(storeUUID: storeUUID, performingContext: taskContext) 58 | } 59 | } 60 | } 61 | 62 | private func performHistoryProcessing(storeUUID: String, performingContext: NSManagedObjectContext) { 63 | /** 64 | Fetch history received from outside the app since the last timestamp 65 | */ 66 | //#-code-listing(fetchHistory) 67 | let lastHistoryToken = historyToken(with: storeUUID) 68 | let request = NSPersistentHistoryChangeRequest.fetchHistory(after: lastHistoryToken) 69 | let historyFetchRequest = NSPersistentHistoryTransaction.fetchRequest! 70 | historyFetchRequest.predicate = NSPredicate(format: "author != %@", TransactionAuthor.app) 71 | request.fetchRequest = historyFetchRequest 72 | 73 | if privatePersistentStore.identifier == storeUUID { 74 | request.affectedStores = [privatePersistentStore] 75 | } else if sharedPersistentStore.identifier == storeUUID { 76 | request.affectedStores = [sharedPersistentStore] 77 | } 78 | //#-end-code-listing 79 | 80 | let result = (try? performingContext.execute(request)) as? NSPersistentHistoryResult 81 | guard let transactions = result?.result as? [NSPersistentHistoryTransaction] else { 82 | return 83 | } 84 | // print("\(#function): Processing transactions: \(transactions.count).") 85 | 86 | /** 87 | Post transactions so observers can update UI if necessary, even when transactions is empty, 88 | because when a share changes, Core Data triggers a store remote change notification with no transaction. 89 | */ 90 | let userInfo: [String: Any] = [UserInfoKey.storeUUID: storeUUID, UserInfoKey.transactions: transactions] 91 | NotificationCenter.default.post(name: .cdcksStoreDidChange, object: self, userInfo: userInfo) 92 | /** 93 | Update the history token using the last transaction. The last transaction has the latest token. 94 | */ 95 | if let newToken = transactions.last?.token { 96 | updateHistoryToken(with: storeUUID, newToken: newToken) 97 | } 98 | 99 | /** 100 | Limit to the private store so only owners can deduplicate the tags. Owners have full access to the private database, and so 101 | don't need to worry about the permissions. 102 | */ 103 | guard !transactions.isEmpty, storeUUID == privatePersistentStore.identifier else { 104 | return 105 | } 106 | /** 107 | Deduplicate the new tags. 108 | Only tags that are not shared or have the same share are deduplicated. 109 | */ 110 | var newTagObjectIDs = [NSManagedObjectID]() 111 | let tagEntityName = Tag.entity().name 112 | 113 | for transaction in transactions where transaction.changes != nil { 114 | for change in transaction.changes! { 115 | if change.changedObjectID.entity.name == tagEntityName && change.changeType == .insert { 116 | newTagObjectIDs.append(change.changedObjectID) 117 | } 118 | } 119 | } 120 | if !newTagObjectIDs.isEmpty { 121 | deduplicateAndWait(tagObjectIDs: newTagObjectIDs) 122 | } 123 | } 124 | 125 | /** 126 | Track the last history tokens for the stores. 127 | The historyQueue reads the token when executing operations, and updates it after completing the processing. 128 | Access this user default from the history queue. 129 | */ 130 | private func historyToken(with storeUUID: String) -> NSPersistentHistoryToken? { 131 | let key = "HistoryToken" + storeUUID 132 | if let data = UserDefaults.standard.data(forKey: key) { 133 | return try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSPersistentHistoryToken.self, from: data) 134 | } 135 | return nil 136 | } 137 | 138 | private func updateHistoryToken(with storeUUID: String, newToken: NSPersistentHistoryToken) { 139 | let key = "HistoryToken" + storeUUID 140 | let data = try? NSKeyedArchiver.archivedData(withRootObject: newToken, requiringSecureCoding: true) 141 | UserDefaults.standard.set(data, forKey: key) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Persistence/PersistenceController+Photo.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | An extension that wraps the methods related to the Photo entity. 5 | 6 | 7 | */ 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | // MARK: - Convenient methods for managing photos. 13 | // 14 | extension PersistenceController { 15 | func addPhoto(photoData: Data, thumbnailData: Data, tagNames: [String] = [], context: NSManagedObjectContext) { 16 | context.perform { 17 | let photo = Photo(context: context) 18 | photo.uniqueName = UUID().uuidString 19 | 20 | let thumbnail = Thumbnail(context: context) 21 | thumbnail.data = thumbnailData 22 | thumbnail.photo = photo 23 | 24 | let photoDataObject = PhotoData(context: context) 25 | photoDataObject.data = photoData 26 | photoDataObject.photo = photo 27 | 28 | for tagName in tagNames { 29 | let existingTag = Tag.tagIfExists(with: tagName, context: context) 30 | let tag = existingTag ?? Tag(context: context) 31 | tag.name = tagName 32 | tag.addToPhotos(photo) 33 | } 34 | 35 | context.save(with: .addPhoto) 36 | } 37 | } 38 | 39 | func delete(photo: Photo) { 40 | if let context = photo.managedObjectContext { 41 | context.perform { 42 | context.delete(photo) 43 | context.save(with: .deletePhoto) 44 | } 45 | } 46 | } 47 | 48 | func photoTransactions(from notification: Notification) -> [NSPersistentHistoryTransaction] { 49 | var results = [NSPersistentHistoryTransaction]() 50 | if let transactions = notification.userInfo?[UserInfoKey.transactions] as? [NSPersistentHistoryTransaction] { 51 | let photoEntityName = Photo.entity().name 52 | for transaction in transactions where transaction.changes != nil { 53 | for change in transaction.changes! where change.changedObjectID.entity.name == photoEntityName { 54 | results.append(transaction) 55 | break // Jump to the next transaction. 56 | } 57 | } 58 | } 59 | return results 60 | } 61 | 62 | func mergeTransactions(_ transactions: [NSPersistentHistoryTransaction], to context: NSManagedObjectContext) { 63 | context.perform { 64 | for transaction in transactions { 65 | context.mergeChanges(fromContextDidSave: transaction.objectIDNotification()) 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Persistence/PersistenceController+Rating.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | An extension that wraps the methods related to the Rating entity. 5 | 6 | 7 | */ 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | // MARK: - Convenient methods for managing tags. 13 | // 14 | extension PersistenceController { 15 | 16 | func addRating(value: Int16, relateTo photo: Photo) { 17 | if let context = photo.managedObjectContext { 18 | context.performAndWait { 19 | let rating = Rating(context: context) 20 | rating.value = value 21 | rating.photo = photo 22 | context.save(with: .addRating) 23 | } 24 | } 25 | } 26 | 27 | func deleteRating(_ rating: Rating) { 28 | if let context = rating.managedObjectContext { 29 | context.performAndWait { 30 | context.delete(rating) 31 | context.save(with: .deleteRating) 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Persistence/PersistenceController+Share.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | Extensions that wraps the methods related to sharing. 5 | 6 | 7 | */ 8 | 9 | import Foundation 10 | import CoreData 11 | import UIKit 12 | import CloudKit 13 | 14 | #if os(iOS) // UICloudSharingController is only available on iOS 15 | // MARK: - Convenient methods for managing sharing. 16 | // 17 | extension PersistenceController { 18 | func presentCloudSharingController(photo: Photo) { 19 | /** 20 | Grab the share if the photo is already shared. 21 | */ 22 | var photoShare: CKShare? 23 | if let shareSet = try? persistentContainer.fetchShares(matching: [photo.objectID]), 24 | let (_, share) = shareSet.first { 25 | photoShare = share 26 | } 27 | 28 | let sharingController: UICloudSharingController 29 | if photoShare == nil { 30 | sharingController = newSharingController(unsharedPhoto: photo, persistenceController: self) 31 | } else { 32 | sharingController = UICloudSharingController(share: photoShare!, container: cloudKitContainer) 33 | } 34 | sharingController.delegate = self 35 | /** 36 | Setting the presentation style to .formSheet so no need to specify sourceView, sourceItem or sourceRect. 37 | */ 38 | if let viewController = rootViewController { 39 | sharingController.modalPresentationStyle = .formSheet 40 | viewController.present(sharingController, animated: true) 41 | } 42 | } 43 | 44 | func presentCloudSharingController(share: CKShare) { 45 | let sharingController = UICloudSharingController(share: share, container: cloudKitContainer) 46 | sharingController.delegate = self 47 | /** 48 | Setting the presentation style to .formSheet so no need to specify sourceView, sourceItem or sourceRect. 49 | */ 50 | if let viewController = rootViewController { 51 | sharingController.modalPresentationStyle = .formSheet 52 | viewController.present(sharingController, animated: true) 53 | } 54 | } 55 | 56 | private func newSharingController(unsharedPhoto: Photo, persistenceController: PersistenceController) -> UICloudSharingController { 57 | return UICloudSharingController { (_, completion: @escaping (CKShare?, CKContainer?, Error?) -> Void) in 58 | /** 59 | Doesn't specify a share intentionally so Core Data creates a new share (zone). 60 | CloudKit has a limit on how many zones a database can have, so apps should use existing shares if possible to avoid hitting the limit, 61 | 62 | If the share's publicPermission is CKShareParticipantPermissionNone, only private participants can accept the share. 63 | ( Private participants mean the participants an app adds to a share by calling CKShare.addParticipant.) 64 | If the share is more permissive (hence is a public share), anyone with the shareURL can accept (or "self-add" themselves to) it. 65 | The default value of publicPermission is CKShare.ParticipantPermission.none 66 | */ 67 | self.persistentContainer.share([unsharedPhoto], to: nil) { objectIDs, share, container, error in 68 | if let share = share { 69 | self.configure(share: share) 70 | } 71 | completion(share, container, error) 72 | } 73 | } 74 | } 75 | 76 | private var rootViewController: UIViewController? { 77 | for scene in UIApplication.shared.connectedScenes { 78 | if scene.activationState == .foregroundActive, 79 | let sceneDeleate = (scene as? UIWindowScene)?.delegate as? UIWindowSceneDelegate, 80 | let window = sceneDeleate.window { 81 | return window?.rootViewController 82 | } 83 | } 84 | print("\(#function): Failed to retrieve the window's root view controller.") 85 | return nil 86 | } 87 | } 88 | 89 | extension PersistenceController: UICloudSharingControllerDelegate { 90 | /** 91 | CloudKit triggers the delegate method in two cases: 92 | - A owner stops sharing a share. 93 | - A participant removes themselves from a share by tapping the "Remove Me" button in UICloudSharingController. 94 | 95 | After stopping the sharing, purge the zone or just wait for an import to update the local store. 96 | This sample chooses to purge the zone to avoid stale UI. That triggers a "zone not found" error because UICloudSharingController 97 | has deleted the zone, but doesn't really matter in this context. 98 | 99 | Purging the zone has a caveat: 100 | - When sharing an object from the owner side, Core Data moves the object to the shared zone; 101 | - When calling purgeObjectsAndRecordsInZone, Core Data removes all the objects and records in the zone. 102 | To keep the objects, deep copy the object graph you would like to keep and relate it to an unshared object (relationship). 103 | 104 | The purge API posts an NSPersistentStoreRemoteChange notification after finishing its job, so observe the notification to update 105 | the UI if necessary. 106 | */ 107 | //#-code-listing(cloudSharingControllerDidStopSharing) 108 | func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { 109 | if let share = csc.share { 110 | purgeObjectsAndRecords(with: share) 111 | } 112 | } 113 | //#-end-code-listing 114 | 115 | //#-code-listing(cloudSharingControllerDidSaveShare) 116 | func cloudSharingControllerDidSaveShare(_ csc: UICloudSharingController) { 117 | if let share = csc.share, let persistentStore = share.persistentStore { 118 | persistentContainer.persistUpdatedShare(share, in: persistentStore) { (share, error) in 119 | if let error = error { 120 | print("\(#function): Failed to persist updated share: \(error)") 121 | } 122 | } 123 | } 124 | } 125 | //#-end-code-listing 126 | 127 | func cloudSharingController(_ csc: UICloudSharingController, failedToSaveShareWithError error: Error) { 128 | print("\(#function): Failed to save a share: \(error)") 129 | } 130 | 131 | func itemTitle(for csc: UICloudSharingController) -> String? { 132 | return csc.share?.title ?? "A cool photo" 133 | } 134 | } 135 | #endif 136 | 137 | #if os(watchOS) 138 | extension PersistenceController { 139 | func presentCloudSharingController(share: CKShare) { 140 | print("\(#function): Cloud sharing controller is unavailable on watchOS.") 141 | } 142 | } 143 | #endif 144 | 145 | extension PersistenceController { 146 | 147 | //#-code-listing(shareObject) 148 | func shareObject(_ unsharedObject: NSManagedObject, to existingShare: CKShare?, 149 | completionHandler: ((_ share: CKShare?, _ error: Error?) -> Void)? = nil) 150 | //#-end-code-listing 151 | { 152 | persistentContainer.share([unsharedObject], to: existingShare) { (objectIDs, share, container, error) in 153 | guard error == nil, let share = share else { 154 | print("\(#function): Failed to share an object: \(error!))") 155 | completionHandler?(share, error) 156 | return 157 | } 158 | /** 159 | Deduplicate tags if necessary because adding a photo to an existing share moves the whole object graph to the associated 160 | record zone, which can lead to duplicated tags. 161 | */ 162 | if existingShare != nil { 163 | if let tagObjectIDs = objectIDs?.filter({ $0.entity.name == "Tag" }), !tagObjectIDs.isEmpty { 164 | self.deduplicateAndWait(tagObjectIDs: Array(tagObjectIDs)) 165 | } 166 | } else { 167 | self.configure(share: share) 168 | } 169 | /** 170 | Synchronize the changes on the share to the private persistent store. 171 | */ 172 | self.persistentContainer.persistUpdatedShare(share, in: self.privatePersistentStore) { (share, error) in 173 | if let error = error { 174 | print("\(#function): Failed to persist updated share: \(error)") 175 | } 176 | completionHandler?(share, error) 177 | } 178 | } 179 | } 180 | 181 | /** 182 | Delete the Core Data objects and the records in the CloudKit record zone associcated with the share. 183 | */ 184 | func purgeObjectsAndRecords(with share: CKShare, in persistentStore: NSPersistentStore? = nil) { 185 | guard let store = (persistentStore ?? share.persistentStore) else { 186 | print("\(#function): Failed to find the persistent store for share. \(share))") 187 | return 188 | } 189 | persistentContainer.purgeObjectsAndRecordsInZone(with: share.recordID.zoneID, in: store) { (zoneID, error) in 190 | if let error = error { 191 | print("\(#function): Failed to purge objects and records: \(error)") 192 | } 193 | } 194 | } 195 | 196 | func existingShare(photo: Photo) -> CKShare? { 197 | if let shareSet = try? persistentContainer.fetchShares(matching: [photo.objectID]), 198 | let (_, share) = shareSet.first { 199 | return share 200 | } 201 | return nil 202 | } 203 | 204 | func share(with title: String) -> CKShare? { 205 | let stores = [privatePersistentStore, sharedPersistentStore] 206 | let shares = try? persistentContainer.fetchShares(in: stores) 207 | let share = shares?.first(where: { $0.title == title }) 208 | return share 209 | } 210 | 211 | func shareTitles() -> [String] { 212 | let stores = [privatePersistentStore, sharedPersistentStore] 213 | let shares = try? persistentContainer.fetchShares(in: stores) 214 | return shares?.map { $0.title } ?? [] 215 | } 216 | 217 | private func configure(share: CKShare, with photo: Photo? = nil) { 218 | share[CKShare.SystemFieldKey.title] = "A cool photo" 219 | } 220 | } 221 | 222 | extension PersistenceController { 223 | func addParticipant(emailAddress: String, permission: CKShare.ParticipantPermission = .readWrite, share: CKShare, 224 | completionHandler: ((_ share: CKShare?, _ error: Error?) -> Void)?) { 225 | /** 226 | Use the email address to look up the participant from the private store. Return if the participant doesn't exist. 227 | Use privatePersistentStore directly because only owner may add participants to a share. 228 | */ 229 | let lookupInfo = CKUserIdentity.LookupInfo(emailAddress: emailAddress) 230 | let persistentStore = privatePersistentStore //share.persistentStore! 231 | 232 | persistentContainer.fetchParticipants(matching: [lookupInfo], into: persistentStore) { (results, error) in 233 | guard let participants = results, let participant = participants.first, error == nil else { 234 | completionHandler?(share, error) 235 | return 236 | } 237 | 238 | //#-code-listing(addParticipant) 239 | participant.permission = permission 240 | participant.role = .privateUser 241 | share.addParticipant(participant) 242 | 243 | self.persistentContainer.persistUpdatedShare(share, in: persistentStore) { (share, error) in 244 | if let error = error { 245 | print("\(#function): Failed to persist updated share: \(error)") 246 | } 247 | completionHandler?(share, error) 248 | } 249 | //#-end-code-listing 250 | } 251 | } 252 | 253 | func deleteParticipant(_ participants: [CKShare.Participant], share: CKShare, 254 | completionHandler: ((_ share: CKShare?, _ error: Error?) -> Void)?) { 255 | for participant in participants { 256 | share.removeParticipant(participant) 257 | } 258 | /** 259 | Use privatePersistentStore directly because only owner may delete participants to a share. 260 | */ 261 | persistentContainer.persistUpdatedShare(share, in: privatePersistentStore) { (share, error) in 262 | if let error = error { 263 | print("\(#function): Failed to persist updated share: \(error)") 264 | } 265 | completionHandler?(share, error) 266 | } 267 | } 268 | } 269 | 270 | extension CKShare.ParticipantAcceptanceStatus { 271 | var stringValue: String { 272 | return ["Unknown", "Pending", "Accepted", "Removed"][rawValue] 273 | } 274 | } 275 | 276 | extension CKShare { 277 | var title: String { 278 | guard let date = creationDate else { 279 | return "Share-\(UUID().uuidString)" 280 | } 281 | let formatter = DateFormatter() 282 | formatter.dateStyle = .short 283 | formatter.timeStyle = .short 284 | return "Share-" + formatter.string(from: date) 285 | } 286 | 287 | var persistentStore: NSPersistentStore? { 288 | let persistentContainer = PersistenceController.shared.persistentContainer 289 | let privatePersistentStore = PersistenceController.shared.privatePersistentStore 290 | if let shares = try? persistentContainer.fetchShares(in: privatePersistentStore) { 291 | let zoneIDs = shares.map { $0.recordID.zoneID } 292 | if zoneIDs.contains(recordID.zoneID) { 293 | return privatePersistentStore 294 | } 295 | } 296 | let sharedPersistentStore = PersistenceController.shared.sharedPersistentStore 297 | if let shares = try? persistentContainer.fetchShares(in: sharedPersistentStore) { 298 | let zoneIDs = shares.map { $0.recordID.zoneID } 299 | if zoneIDs.contains(recordID.zoneID) { 300 | return sharedPersistentStore 301 | } 302 | } 303 | return nil 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /Persistence/PersistenceController+Tag.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | Extensions that wrap the methods related to the Tag entity. 5 | 6 | 7 | */ 8 | 9 | import Foundation 10 | import CoreData 11 | import CloudKit 12 | 13 | // MARK: - Convenient methods for managing tags. 14 | // 15 | extension PersistenceController { 16 | func numberOfTags(with tagName: String) -> Int { 17 | let fetchRequest: NSFetchRequest = Tag.fetchRequest() 18 | fetchRequest.predicate = NSPredicate(format: "\(Tag.Schema.name.rawValue) == %@", tagName) 19 | 20 | let number = try? persistentContainer.viewContext.count(for: fetchRequest) 21 | return number ?? 0 22 | } 23 | 24 | func addTag(name: String, relateTo photo: Photo) { 25 | if let context = photo.managedObjectContext { 26 | context.performAndWait { 27 | let tag = Tag(context: context) 28 | tag.name = name 29 | tag.uuid = UUID() 30 | tag.addToPhotos(photo) 31 | context.save(with: .addTag) 32 | } 33 | } 34 | } 35 | 36 | func deleteTag(_ tag: Tag) { 37 | if let context = tag.managedObjectContext { 38 | context.performAndWait { 39 | context.delete(tag) 40 | context.save(with: .deleteTag) 41 | } 42 | } 43 | } 44 | 45 | func toggleTagging(photo: Photo, tag: Tag) { 46 | if let context = photo.managedObjectContext { 47 | context.performAndWait { 48 | if let photoTags = photo.tags, photoTags.contains(tag) { 49 | photo.removeFromTags(tag) 50 | } else { 51 | photo.addToTags(tag) 52 | } 53 | context.save(with: .toggleTagging) 54 | } 55 | } 56 | } 57 | /** 58 | Return the tags that the app can use to tag the specified photo (or in the same CloudKit zone as the photo). 59 | */ 60 | func filterTags(from tags: [Tag], forTagging photo: Photo) -> [Tag] { 61 | guard let context = photo.managedObjectContext else { 62 | print("\(#function): Tagging a photo that isn't in a context is unsupported.") 63 | return [] 64 | } 65 | /** 66 | Fetch the share for the photo 67 | */ 68 | var photoShare: CKShare? 69 | if let result = try? persistentContainer.fetchShares(matching: [photo.objectID]) { 70 | photoShare = result[photo.objectID] 71 | } 72 | /** 73 | Gather the object IDs of the tags that are valid for tagging the photo. 74 | - Tags that are already in photo.tags are valid. 75 | - Tags that have the same share as photoShare is valid. 76 | */ 77 | var filteredTags = [Tag]() 78 | context.performAndWait { 79 | for tag in tags { 80 | if let photoTags = photo.tags, photoTags.contains(tag) { 81 | filteredTags.append(tag) 82 | continue 83 | } 84 | let tagShare = existingShare(tag: tag) 85 | if photoShare?.recordID.zoneID == tagShare?.recordID.zoneID { 86 | filteredTags.append(tag) 87 | } 88 | } 89 | } 90 | return filteredTags 91 | } 92 | 93 | /** 94 | Fetch and return the share of the tag and its related photos. 95 | Consider the related photos as well. 96 | */ 97 | private func existingShare(tag: Tag) -> CKShare? { 98 | var objectIDs = [tag.objectID] 99 | if let photoSet = tag.photos, let photos = Array(photoSet) as? [Photo] { 100 | objectIDs += photos.map { $0.objectID } 101 | } 102 | let result = try? persistentContainer.fetchShares(matching: objectIDs) 103 | return result?.values.first 104 | } 105 | } 106 | 107 | // MARK: - An extension for Tag. 108 | // 109 | extension Tag { 110 | /** 111 | The name of relevant tag attributes. 112 | */ 113 | enum Schema: String { 114 | case name, uuid 115 | } 116 | 117 | class func tagIfExists(with name: String, context: NSManagedObjectContext) -> Tag? { 118 | let fetchRequest: NSFetchRequest = Tag.fetchRequest() 119 | fetchRequest.predicate = NSPredicate(format: "\(Schema.name.rawValue) == %@", name) 120 | let tags = try? context.fetch(fetchRequest) 121 | return tags?.first 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Persistence/PersistenceController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | A class that sets up the Core Data stack. 5 | 6 | 7 | */ 8 | 9 | import Foundation 10 | import CoreData 11 | import CloudKit 12 | import SwiftUI 13 | 14 | let gCloudKitContainerIdentifier = "iCloud.com.example.ziqiao-samplecode.CoreDataCloudKitShare" 15 | 16 | /** 17 | This app doesn't necessarily post notifications from the main queue. 18 | */ 19 | extension Notification.Name { 20 | static let cdcksStoreDidChange = Notification.Name("cdcksStoreDidChange") 21 | } 22 | 23 | struct UserInfoKey { 24 | static let storeUUID = "storeUUID" 25 | static let transactions = "transactions" 26 | } 27 | 28 | struct TransactionAuthor { 29 | static let app = "app" 30 | } 31 | 32 | class PersistenceController: NSObject, ObservableObject { 33 | static let shared = PersistenceController() 34 | 35 | lazy var persistentContainer: NSPersistentCloudKitContainer = { 36 | /** 37 | Prepare the parent folder for the Core Data stores. 38 | A Core Data store has companion files, so it is a good practice to put a store under a folder. 39 | */ 40 | let baseURL = NSPersistentContainer.defaultDirectoryURL() 41 | let storeFolderURL = baseURL.appendingPathComponent("CoreDataStores") 42 | let privateStoreFolderURL = storeFolderURL.appendingPathComponent("Private") 43 | let sharedStoreFolderURL = storeFolderURL.appendingPathComponent("Shared") 44 | 45 | let fileManager = FileManager.default 46 | for folderURL in [privateStoreFolderURL, sharedStoreFolderURL] where !fileManager.fileExists(atPath: folderURL.path) { 47 | do { 48 | try fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil) 49 | } catch { 50 | fatalError("#\(#function): Failed to create the store folder: \(error)") 51 | } 52 | } 53 | 54 | let container = NSPersistentCloudKitContainer(name: "CoreDataCloudKitShare") 55 | 56 | /** 57 | Grab the default (first) store and associate it with the CloudKit private database. 58 | Set up the store description by: 59 | - Specifying a file name for the store. 60 | - Enabling history tracking and remote notifications. 61 | - Specifying the iCloud container and database scope. 62 | */ 63 | guard let privateStoreDescription = container.persistentStoreDescriptions.first else { 64 | fatalError("#\(#function): Failed to retrieve a persistent store description.") 65 | } 66 | privateStoreDescription.url = privateStoreFolderURL.appendingPathComponent("private.sqlite") 67 | 68 | //#-code-listing(setOption) 69 | privateStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) 70 | privateStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) 71 | //#-end-code-listing 72 | 73 | //#-code-listing(NSPersistentCloudKitContainerOptions) 74 | let cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: gCloudKitContainerIdentifier) 75 | //#-end-code-listing 76 | 77 | cloudKitContainerOptions.databaseScope = .private 78 | privateStoreDescription.cloudKitContainerOptions = cloudKitContainerOptions 79 | 80 | /** 81 | Similarly, add a second store and associate it with the CloudKit shared database. 82 | */ 83 | guard let sharedStoreDescription = privateStoreDescription.copy() as? NSPersistentStoreDescription else { 84 | fatalError("#\(#function): Copying the private store description returned an unexpected value.") 85 | } 86 | sharedStoreDescription.url = sharedStoreFolderURL.appendingPathComponent("shared.sqlite") 87 | 88 | let sharedStoreOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: gCloudKitContainerIdentifier) 89 | sharedStoreOptions.databaseScope = .shared 90 | sharedStoreDescription.cloudKitContainerOptions = sharedStoreOptions 91 | 92 | /** 93 | Load the persistent stores 94 | */ 95 | container.persistentStoreDescriptions.append(sharedStoreDescription) 96 | container.loadPersistentStores(completionHandler: { (loadedStoreDescription, error) in 97 | guard error == nil else { 98 | fatalError("#\(#function): Failed to load persistent stores:\(error!)") 99 | } 100 | guard let cloudKitContainerOptions = loadedStoreDescription.cloudKitContainerOptions else { 101 | return 102 | } 103 | if cloudKitContainerOptions.databaseScope == .private { 104 | self._privatePersistentStore = container.persistentStoreCoordinator.persistentStore(for: loadedStoreDescription.url!) 105 | } else if cloudKitContainerOptions.databaseScope == .shared { 106 | self._sharedPersistentStore = container.persistentStoreCoordinator.persistentStore(for: loadedStoreDescription.url!) 107 | } 108 | }) 109 | 110 | /** 111 | Run initializeCloudKitSchema() once to update the CloudKit schema every time you change the Core Data model. 112 | Do not call this code in the production environment. 113 | */ 114 | #if InitializeCloudKitSchema 115 | do { 116 | try container.initializeCloudKitSchema() 117 | } catch { 118 | print("\(#function): initializeCloudKitSchema: \(error)") 119 | } 120 | #else 121 | container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy 122 | container.viewContext.transactionAuthor = TransactionAuthor.app 123 | 124 | /** 125 | Automatically merge the changes from other contexts. 126 | */ 127 | container.viewContext.automaticallyMergesChangesFromParent = true 128 | 129 | /** 130 | Pin the viewContext to the current generation token and set it to keep itself up to date with local changes. 131 | */ 132 | do { 133 | try container.viewContext.setQueryGenerationFrom(.current) 134 | } catch { 135 | fatalError("#\(#function): Failed to pin viewContext to the current generation:\(error)") 136 | } 137 | 138 | /** 139 | Observe the following notifications: 140 | - The remote change notifications from container.persistentStoreCoordinator 141 | - The .NSManagedObjectContextDidSave notifications from any context. 142 | - The event change notifications from the container. 143 | */ 144 | NotificationCenter.default.addObserver(self, selector: #selector(storeRemoteChange(_:)), 145 | name: .NSPersistentStoreRemoteChange, 146 | object: container.persistentStoreCoordinator) 147 | NotificationCenter.default.addObserver(self, selector: #selector(containerEventChanged(_:)), 148 | name: NSPersistentCloudKitContainer.eventChangedNotification, 149 | object: container) 150 | #endif 151 | return container 152 | }() 153 | 154 | private var _privatePersistentStore: NSPersistentStore? 155 | var privatePersistentStore: NSPersistentStore { 156 | return _privatePersistentStore! 157 | } 158 | 159 | private var _sharedPersistentStore: NSPersistentStore? 160 | var sharedPersistentStore: NSPersistentStore { 161 | return _sharedPersistentStore! 162 | } 163 | 164 | lazy var cloudKitContainer: CKContainer = { 165 | return CKContainer(identifier: gCloudKitContainerIdentifier) 166 | }() 167 | 168 | /** 169 | An operation queue for handling history processing tasks: watching changes, deduplicating tags, and triggering UI updates if needed. 170 | */ 171 | lazy var historyQueue: OperationQueue = { 172 | let queue = OperationQueue() 173 | queue.maxConcurrentOperationCount = 1 174 | return queue 175 | }() 176 | } 177 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sharing Core Data Objects Between iCloud Users 2 | Implement the flow to share data between iCloud users using Core Data CloudKit. 3 | 4 | ## Overview 5 | More and more people own multiple devices and use them to share digital assets or collaborate work. They expect seamless data synchronization across their devices and an easy way to share data with privacy and security in mind. Apps can support such use cases by moving user data to CloudKit and implementing a data sharing flow that includes features like share management and access control. 6 | 7 | This sample app demonstrates how to use Core Data CloudKit to share photos between iCloud users. Users who share photos, called _owners_, can create a share, send out an invitation, manage the permissions, and stop the sharing. Users who accept the share, called _participants_, can view or edit the photos, or stop participating the share. 8 | 9 | ## Configure the Sample Code Project 10 | Before building the sample app, perform the following steps in Xcode: 11 | 1. In the General pane of the `CoreDataCloudKitShare` target, update the Bundle Identifier field with a new identifier. 12 | 2. In the Signing & Capabilities pane, select the applicable team from the Team drop-down menu to let Xcode automatically manage the provisioning profile. See [Assign a project to a team](https://help.apple.com/xcode/mac/current/#/dev23aab79b4) for details. 13 | 3. Make sure the iCloud capability is present and the CloudKit option is in a selected state, then select the iCloud container with your bundle identifier from step 1 from the Containers list. If the container doesn’t exist, click the Add button (+), enter the container name (iCloud.<*bundle identifier*>), and click OK to let Xcode create the container and associate it with the app. 14 | 4. If you prefer using an existing container, select it from the Containers list. 15 | 5. Specify your iCloud container for the `gCloudKitContainerIdentifier` variable in PersistenceController.swift. An iCloud container identifier is case-sensitive and must begin with "`iCloud.`". 16 | 6. Similar to step 1, change the bundle identifiers and the developer team for the WatchKit app and WatchKit Extension targets. The bundle identifiers must be `.watchkitapp` and `.watchkitapp.watchkitextension` respectively. 17 | 7. Similar to step 2, specify the iCloud container for the WatchKit Extension target. To synchronize data across iCloud, the iOS app and WatchKit extension must share the same iCloud container. 18 | 8. Open the Info.plist file of the WatchKit app target, then change the value of WKCompanionAppBundleIdentifier key to ``. 19 | 9. Open the Info.plist file of the WatchKit Extension target, then change the value of NSExtension > NSExtensionAttributes > WKAppBundleIdentifier key to `.watchkitapp`. 20 | 21 | To run the sample app on a device, configure the device as follows: 22 | 1. Log in with an Apple ID. For the CloudKit private database to synchronize, the Apple ID must be the same on the devices. (For an Apple Watch, log in at the Watch app on the paired iPhone, then make sure the Apple ID shows up on the Settings app on the watch.) 23 | 2. For an iOS device, choose Settings > Apple ID > iCloud, and turn on iCloud Drive, if it is off. 24 | 3. After running the sample app on the device, go to Settings > Notifications, and make sure “Allow Notifications” is on. For an Apple Watch, use the Watch app on the paired iPhone to make sure that notifications are on for the app. 25 | 26 | To create and configure a new project that uses Core Data CloudKit, see [Setting Up Core Data with CloudKit](https://developer.apple.com/documentation/coredata/mirroring_a_core_data_store_with_cloudkit/setting_up_core_data_with_cloudkit?changes=__3). 27 | 28 | ## Create the CloudKit Schema for Apps 29 | CloudKit apps must have a schema to declare the data types they use. When apps create a record in the CloudKit development environment, CloudKit automatically creates the record type if it doesn't exist. In the production environment, CloudKit doesn't have that capability, nor does it allow removing an existing record type or field, so after finalizing the schema, be sure to deploy it to the production environment. Without doing that, apps that work in the production environment, like the App Store or TestFlight ones, would not work. For more information, see [Deploying an iCloud Container’s Schema](https://developer.apple.com/documentation/cloudkit/managing_icloud_containers_with_the_cloudkit_database_app/deploying_an_icloud_container_s_schema). 30 | 31 | Core Data CloudKit apps can use [`initializeCloudKitSchema(options:)`](https://developer.apple.com/documentation/coredata/nspersistentcloudkitcontainer/3343548-initializecloudkitschema) to create the CloudKit schema that matches their Core Data model, or keep it up to date every time their model changes. The method works by creating fake data for the record types and then delete it, which can take some time and blocks the other CloudKit operations. Apps must not call it in the production environment, or in the normal development process that doesn't include model changes. 32 | 33 | To create the CloudKit schema for this sample app, pick the "InitializeCloudKitSchema" target from Xcode's target menu, and run it. Having a target dedicated on CloudKit schema creation separates the `initializeCloudKitSchema(options:)` call from the normal flow. After running the target, be sure to check with [CloudKit Console](http://icloud.developer.apple.com/dashboard/) if every Core Data entity and attribute has a CloudKit counterpart. See [Reading CloudKit Records for Core Data](https://developer.apple.com/documentation/coredata/mirroring_a_core_data_store_with_cloudkit/reading_cloudkit_records_for_core_data) for the detailed mapping rules. 34 | 35 | For apps that use CloudKit public database, manually add a `Queryable` index for the `recordName` and `modifiedAt` fields of all record types, including the `CDMR` type that Core Data generates to manage many-to-many relationships. 36 | 37 | For more information on this topic, see [Creating a Core Data Model for CloudKit](https://developer.apple.com/documentation/coredata/mirroring_a_core_data_store_with_cloudkit/creating_a_core_data_model_for_cloudkit) 38 | 39 | ## Try out the Sharing Flow With the Sample App 40 | To create and share a photo using the sample app, follow these steps: 41 | 1. Prepare two iOS devices, A and B, and log in with a different Apple ID. 42 | 2. Use Xcode to build and run the sample app on the devices. 43 | 3. On device A, tap the Add(+) button to show the photo picker, then pick a photo and add it to the Core Data store. 44 | 4. Long press the photo to show the action menu, then tap the "Create New Share" button to present the CloudKit sharing UI. 45 | 5. Follow the UI to send a link to the Apple ID on device B. Try to use iMessage because it's easier to set up. 46 | 6. After receiving the link on device B, tap it to accept and open the share, which launches the sample app and shows the photo. 47 | 48 | To discover more features of the sample app: 49 | - On device A, add another photo, long press it and tap the "Add to Existing Share" button, then pick a share and tap the "Add" button. See the photo soon appears on Device B. 50 | - On device B, long press the photo, tap the "Manage Participation" button to present the CloudKit sharing UI, then pick the Apple ID that has "(Me)" suffix and tap "Remove Me" to remove the participation. See the photo disappears. 51 | - Tap the "Manage Shares" button, then pick the share, and try to manage its participants using [`UICloudSharingController`](https://developer.apple.com/documentation/uikit/uicloudsharingcontroller) or the app UI. 52 | 53 | It may take some time (minutes or longer) for one user to see the changes from the others. Core Data CloudKit is not for real-time synchronization. When users change the store on their device, it is up to the system to determine when to synchronize the change. There is no API for apps to speed up, slow down, or choose the timing for the synchronization. 54 | 55 | ## Set up the Core Data Stack 56 | Every CloudKit container has a [private database](https://developer.apple.com/documentation/cloudkit/ckcontainer/1399205-privateclouddatabase) and a [shared database](https://developer.apple.com/documentation/cloudkit/ckcontainer/1640408-sharedclouddatabase). To mirror these databases, set up a Core Data stack with two stores, and set the store's [database scope](https://developer.apple.com/documentation/coredata/nspersistentcloudkitcontaineroptions/3580372-databasescope?changes=__3) to `.private` and `.shared` respectively. 57 | 58 | When setting up the store description, enable [persistent history](https://developer.apple.com/documentation/coredata/persistent_history) tracking and turn on remote change notifications by setting the `NSPersistentHistoryTrackingKey` and `NSPersistentStoreRemoteChangeNotificationPostOptionKey` options to `true`. Core Data relies on the persistent history to track the store changes, and apps need to update their UI when remote changes occur. 59 | 60 | - CodeListing: setOption 61 | 62 | For apps (under the same developer team) to synchronize data through CloudKit, they must use the same CloudKit container. This sample app explicitly specifies the same container for its iOS and watchOS apps when setting up the CloudKit container options: 63 | 64 | - CodeListing: NSPersistentCloudKitContainerOptions 65 | 66 | ## Share a Core Data object 67 | Sharing a Core Data object between iCloud users includes the following tasks: 68 | 1. On the owner side, create a share with an appropriate permission. 69 | 2. Invite participants by making the share link available to them. 70 | 3. On the participant side, accept the share. 71 | 4. On both sides, manage shares. Owners can stop sharing the object, change the share permission for a participant. Participants can stop their participation. 72 | 73 | [`NSPersistentCloudKitContainer`](https://developer.apple.com/documentation/coredata/nspersistentcloudkitcontainer?changes=__3) provides methods for creating a share ([`CKShare`](https://developer.apple.com/documentation/cloudkit/ckshare)) for Core Data objects and managing the interaction between the share and the associated objects. `UICloudSharingController` implements the share invitation and management. Apps can implement a sharing flow using these two APIs. 74 | 75 | To create a share for Core Data objects, call [`share(_:to:completion:)`](https://developer.apple.com/documentation/coredata/nspersistentcloudkitcontainer/3746834-share?changes=__3). Apps can choose creating a new share, or adding the objects to an existing share. Core Data uses CloudKit zone sharing so each share has its own record zone on the CloudKit server. (For more details, see [WWDC21 session 10015: Build Apps that Share Data Through CloudKit and Core Data](https://developer.apple.com/videos/play/wwdc2021/10015/) and [WWDC21 session 10086: What's new in CloudKit](https://developer.apple.com/videos/play/wwdc2021/10086).) CloudKit has a limit on how many record zones a database can have. To avoid hitting the limit, consider using an existing share if appropriate. 76 | 77 | See the following method for how this sample app shares a photo: 78 | - CodeListing: shareObject 79 | 80 | `NSPersistentCloudKitContainer` doesn't automatically handle the changes `UICloudSharingController` (or other CloudKit APIs) makes on a share. When the kind of changes happen, apps must update the Core Data store by calling [`persistUpdatedShare(_:in:completion:)`](https://developer.apple.com/documentation/coredata/nspersistentcloudkitcontainer/3746832-persistupdatedshare?changes=__3). The sample app implements the following [`UICloudSharingControllerDelegate`](https://developer.apple.com/documentation/uikit/uicloudsharingcontrollerdelegate) method to persist a updated share. 81 | 82 | - CodeListing: cloudSharingControllerDidSaveShare 83 | 84 | Similarly, when owners tap the "Stop Sharing" button or participants tap the "Remove Me" button in the CloudKit sharing UI, `NSPersistentCloudKitContainer` doesn't immediately know the change. To avoid stale UI in this case, implement the following delegate method to purge the Core Data objects and CloudKit records associated with the share using [`purgeObjectsAndRecordsInZone(with:in:completion:)`](https://developer.apple.com/documentation/coredata/nspersistentcloudkitcontainer/3746833-purgeobjectsandrecordsinzone?changes=__3). 85 | 86 | - CodeListing: cloudSharingControllerDidStopSharing 87 | 88 | Core Data doesn't support cross-share relationships. That is, it doesn't allow relating objects associated with different shares. When sharing an object, Core Data moves the whole object graph (including the object and all its relationships) to the share's record zone. When users stop a share, Core Data deletes the object graph. In the case where apps need to reserve the data when users stopping a share, make a deep copy of the object graph and make sure no object in the graph is associated with any share. 89 | 90 | ## Detect Relevant Changes by Consuming Store Persistent History 91 | When importing data from CloudKit, `NSPersistentCloudKitContainer` records the changes on Core Data objects in the store's persistent history, and triggers remote change notifications (`.NSPersistentStoreRemoteChange`) so apps can keep their state up to date if necessary. The sample app observes the notification and does the followings in the notification handler: 92 | 93 | - Gather the relevant history transactions ([`NSPersistentHistoryTransaction`](https://developer.apple.com/documentation/coredata/nspersistenthistorytransaction)), and notify the views that remote changes happen. Note that the changes on shares don't generate any transactions. 94 | - The views that present photos merge the transactions to the `viewContext` of the persistent container, which triggers a SwiftUI update. Views relevant to shares fetch the shares from the stores, and update with them. 95 | - Detect the new tags from CloudKit, and remove duplicate tags if necessary. 96 | 97 | To process the persistent history more effectively, the app: 98 | - Maintains the token of the last transaction it consumes for each store, and uses it as the starting point of next run. 99 | - Maintains a transaction author, and uses it to filter the transactions irrelevant to Core Data CloudKit. 100 | - Only fetches and consumes the history of the relevant persistent store. 101 | 102 | This is the code that sets up the history fetch request (`NSPersistentHistoryChangeRequest`): 103 | - CodeListing: fetchHistory 104 | 105 | For more information about persistent history processing, see [Consuming Relevant Store Changes](https://developer.apple.com/documentation/coredata/consuming_relevant_store_changes). 106 | 107 | ## Remove Duplicate Data 108 | In the CloudKit environment, duplicate data is sometimes inevitable: 109 | - Different peers can create same data. In this sample app, owners can share a photo with a permission that allows participants to tag it. When owners and participants simultaneously create a same tag, a duplicate occurs. 110 | - Apps rely on some initial data and there is no way to allow only one peer to preload it. Duplicates occur when multiple peers preload the data at the same time. 111 | 112 | To remove duplicate data (or _deduplicate_), implement a way that allows all peers to eventually reserve the same winner and remove others. The sample app removes duplicate tags in the following way: 113 | 114 | 1. Give every tag a universally unique identifier (UUID). Tags that meet the following criteria are duplicates and only one should exist: 115 | - They have a same tag name. (Their UUIDs are still different.) 116 | - They are associated with a same share, and so are in the same CloudKit record zone. 117 | 2. Detect new tags from CloudKit by looking into the persistent history every time a remote change notification occurs. 118 | 3. For each new tag, fetch the duplicates from the same persistent store, and sort them with their UUID so the tag with the smallest UUID goes first. 119 | 4. Pick the first tag as the winner and remove the others. Because UUID is globally unique and every peer picks the first tag, all peers eventually reach to the same winner, which is the tag that has the globally smallest UUID. 120 | 121 | The sample app only detects and removes duplicate tags from the owner side because participants may not have write permission. That is, deduplication only applies to the private persistent store. 122 | 123 | See the following method for the code that deduplicate tags: 124 | 125 | - CodeListing: deduplicateAndWait 126 | 127 | ## Implement a Custom Sharing Flow 128 | When `UICloudSharingController` is unavailable or doesn't fit the app UI, consider implementing a custom sharing flow if necessary. (`UICloudSharingController` is unavailabe on watchOS. On macOS, use [`NSSharingService`](https://developer.apple.com/documentation/appkit/nssharingservice) with the [`.cloudSharing`](https://developer.apple.com/documentation/appkit/nssharingservice/name/1644670-cloudsharing) service.) To do that, here are the steps and relevant APIs: 129 | 130 | 1. On the owner side, pick the Core Data objects to share, and create a share with them using `share(_:to:completion:)`. 131 | 2. Configure the share with appropriate permissions, and add participants if it's a private share. 132 | A share is private if its [`publicPermission`](https://developer.apple.com/documentation/cloudkit/ckshare/1640494-publicpermission) is more permissive than [`.none`](https://developer.apple.com/documentation/cloudkit/ckshare/participantpermission/none). For shares that have `.none` public permission (called _public shares_), users can participate by tapping the share link, hence no need to add participants beforehand. Look up the participants using [`fetchParticipants(matching:into:completion:)`](https://developer.apple.com/documentation/coredata/nspersistentcloudkitcontainer/3746829-fetchparticipants) or [`CKFetchShareParticipantsOperation`](https://developer.apple.com/documentation/cloudkit/ckfetchshareparticipantsoperation), then add them to the share by calling [`addParticipant(_:)`](https://developer.apple.com/documentation/cloudkit/ckshare/1640443-addparticipant). Configure the participant permission using [`CKShare.ParticipantPermission`](https://developer.apple.com/documentation/cloudkit/ckshare/participant/1640433-permission). 133 | 3. Implement a mechanism for the owner to deliver the share link ([`CKShare.url`](https://developer.apple.com/documentation/cloudkit/ckshare/1640465-url)). 134 | 4. On the participant side, accept the share. 135 | After receiving the share link, participants tap it to accept the share and open the app. The system calls [`windowScene(_:userDidAcceptCloudKitShareWith:)`](https://developer.apple.com/documentation/uikit/uiwindowscenedelegate/3238089-windowscene) (or [`userDidAcceptCloudKitShare(with:)`](https://developer.apple.com/documentation/watchkit/wkextensiondelegate/3612144-userdidacceptcloudkitshare) on watchOS) when launching the app in this context, and the app accepts the share using [`acceptShareInvitations(from:into:completion:)`](https://developer.apple.com/documentation/coredata/nspersistentcloudkitcontainer/3746828-acceptshareinvitations) or [`CKAcceptSharesOperation`](https://developer.apple.com/documentation/cloudkit/ckacceptsharesoperation). After the acceptance synchronizes, the objects the owner shares are available in the participant's store that mirrors the CloudKit shared database. 136 | 5. On the owner side, manage the participants of the share using `addParticipant(_:)` and `removeParticipant(_:)`, or stop the sharing by calling `purgeObjectsAndRecordsInZone(with:in:completion:)`. 137 | 6. On the participant side, stop the participation by calling `purgeObjectsAndRecordsInZone(with:in:completion:)`. 138 | 139 | In the whole process, whenever changing a share using CloudKit APIs, call `persistUpdatedShare(_:in:completion:)` so Core Data persists the change to the store and synchronize it with CloudKit. As an example, this sample uses the following code to add a participant 140 | 141 | - CodeListing: addParticipant 142 | 143 | - Note: To be able to accept a share when users tap a share link, the app's `info.plist` file must contain the `CKSharingSupported` key and its value must be `true`. 144 | -------------------------------------------------------------------------------- /SwiftUI/AddToExistingShareView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | A SwiftUI view that adds a photo to an existing share. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import CoreData 11 | import CloudKit 12 | 13 | struct AddToExistingShareView: View { 14 | @Binding var isPresented: ActiveSheet? 15 | var photo: Photo 16 | 17 | @State private var toggleProgress: Bool = false 18 | @State private var selection: String? 19 | 20 | var body: some View { 21 | ZStack { 22 | SharePickerView(isPresented: $isPresented, selection: $selection) { 23 | Button("Add", action: { 24 | sharePhoto(photo, shareTitle: selection) 25 | }) 26 | .disabled(selection == nil) 27 | } 28 | if toggleProgress { 29 | ProgressView() 30 | } 31 | } 32 | } 33 | 34 | private func sharePhoto(_ unsharedPhoto: Photo, shareTitle: String?) { 35 | toggleProgress.toggle() 36 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 37 | let persistenceController = PersistenceController.shared 38 | if let shareTitle = shareTitle, let share = persistenceController.share(with: shareTitle) { 39 | persistenceController.shareObject(unsharedPhoto, to: share) 40 | } 41 | toggleProgress.toggle() 42 | isPresented = nil 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /SwiftUI/FullImageView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | A SwiftUI view that shows a scrollable full size image. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import CoreData 11 | 12 | struct FullImageView: View { 13 | @Binding var isPresented: ActiveCover? 14 | var photo: Photo 15 | 16 | private var photoImage: UIImage? { 17 | let photoData = photo.photoData?.data 18 | return photoData != nil ? UIImage(data: photoData!) : nil 19 | } 20 | 21 | var body: some View { 22 | NavigationView { 23 | VStack { 24 | if let image = photoImage { 25 | ScrollView([.horizontal, .vertical]) { 26 | Image(uiImage: image) 27 | } 28 | } else { 29 | Text("The full size image is probably not downloaded from CloudKit.").padding() 30 | Spacer() 31 | } 32 | } 33 | .toolbar { 34 | ToolbarItem(placement: .automatic) { 35 | Button("Dismiss", action: { isPresented = nil }) 36 | } 37 | } 38 | .listStyle(PlainListStyle()) 39 | .navigationTitle("Full Size Photo") 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /SwiftUI/ManagingSharesView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | A SwiftUI view that manages existing shares. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import CoreData 11 | import CloudKit 12 | 13 | struct ManagingSharesView: View { 14 | @Binding var isPresented: ActiveSheet? 15 | @Binding var nextSheet: ActiveSheet? 16 | 17 | @State private var toggleProgress: Bool = false 18 | @State private var selection: String? 19 | 20 | var body: some View { 21 | ZStack { 22 | SharePickerView(isPresented: $isPresented, selection: $selection) { 23 | if let shareTitle = selection, let share = PersistenceController.shared.share(with: shareTitle) { 24 | actionButtons(for: share) 25 | } 26 | } 27 | if toggleProgress { 28 | ProgressView() 29 | } 30 | } 31 | } 32 | 33 | @ViewBuilder 34 | private func actionButtons(for share: CKShare) -> some View { 35 | let persistentStore = share.persistentStore 36 | let isPrivateStore = (persistentStore == PersistenceController.shared.privatePersistentStore) 37 | 38 | Button(isPrivateStore ? "Manage Participants" : "View Participants", action: { 39 | if let share = PersistenceController.shared.share(with: selection!) { 40 | nextSheet = .participantView(share) 41 | isPresented = nil 42 | } 43 | }) 44 | .disabled(selection == nil) 45 | 46 | Button(isPrivateStore ? "Stop Sharing" : "Remove Me", action: { 47 | if let share = PersistenceController.shared.share(with: selection!) { 48 | purgeShare(share, in: persistentStore) 49 | } 50 | }) 51 | .disabled(selection == nil) 52 | 53 | #if os(iOS) 54 | Button("Manage With UICloudSharingController", action: { 55 | if let share = PersistenceController.shared.share(with: selection!) { 56 | nextSheet = .cloudSharingSheet(share) 57 | isPresented = nil 58 | } 59 | }) 60 | .disabled(selection == nil) 61 | #endif 62 | } 63 | 64 | private func purgeShare(_ share: CKShare, in persistentStore: NSPersistentStore?) { 65 | toggleProgress.toggle() 66 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 67 | PersistenceController.shared.purgeObjectsAndRecords(with: share, in: persistentStore) 68 | toggleProgress.toggle() 69 | isPresented = nil 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /SwiftUI/ParticipantView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | A SwiftUI view that manages the participants of a share. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import CoreData 11 | import CloudKit 12 | 13 | /** 14 | Managing a participant only makes sense when the share exists and is a private share. 15 | A private share is a share whose publicPermission equals to .none. 16 | A public share means a share whose publicPermission is more permissive. Any person who has the share link can 17 | self-add themselves to a public share. 18 | */ 19 | struct ParticipantView: View { 20 | @Binding var isPresented: ActiveSheet? 21 | private let share: CKShare 22 | 23 | @State private var toggleProgress: Bool = false 24 | @State private var participants: [Participant] 25 | @State private var wasShareDeleted = false 26 | 27 | private let canUpdateParticipants: Bool 28 | 29 | init(isPresented: Binding, share: CKShare) { 30 | _isPresented = isPresented 31 | self.share = share 32 | participants = share.participants.filter { $0.role != .owner }.map { Participant($0) } 33 | 34 | let privateStore = PersistenceController.shared.privatePersistentStore 35 | canUpdateParticipants = (share.persistentStore == privateStore) 36 | } 37 | 38 | var body: some View { 39 | NavigationView { 40 | VStack { 41 | if wasShareDeleted { 42 | Text("The share was deleted remotely.").padding() 43 | Spacer() 44 | } else { 45 | participantListView() 46 | } 47 | } 48 | .toolbar { toolbarItems() } 49 | .listStyle(PlainListStyle()) 50 | .navigationTitle("Participants") 51 | } 52 | .onReceive(NotificationCenter.default.storeDidChangePublisher) { notification in 53 | processStoreChangeNotification(notification) 54 | } 55 | } 56 | 57 | /** 58 | List -> Section header + section content triggers a strange animation when deleting an item. 59 | Moving the header out (like below) fixes the animation issue, but the toolbar item doesn't work on watchOS. 60 | ParticipantListHeader(participants: $participants, share: share) 61 | .padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 0)) 62 | List { 63 | SectionContent() 64 | } 65 | */ 66 | @ViewBuilder 67 | private func participantListView() -> some View { 68 | ZStack { 69 | List { 70 | Section(header: sectionHeader()) { 71 | sectionContent() 72 | } 73 | } 74 | if toggleProgress { 75 | ProgressView() 76 | } 77 | } 78 | } 79 | 80 | @ViewBuilder 81 | private func sectionHeader() -> some View { 82 | if canUpdateParticipants { 83 | ParticipantListHeader(toggleProgress: $toggleProgress, 84 | participants: $participants, share: share) 85 | } else { 86 | EmptyView() 87 | } 88 | } 89 | 90 | @ViewBuilder 91 | private func sectionContent() -> some View { 92 | ForEach(participants, id: \.self) { participant in 93 | HStack { 94 | Text(participant.ckShareParticipant.userIdentity.lookupInfo?.emailAddress ?? "") 95 | Spacer() 96 | Text(participant.ckShareParticipant.acceptanceStatus.stringValue) 97 | } 98 | } 99 | .onDelete(perform: canUpdateParticipants ? deleteParticipant : nil) 100 | } 101 | 102 | @ToolbarContentBuilder 103 | private func toolbarItems() -> some ToolbarContent { 104 | ToolbarItem(placement: .automatic) { 105 | Button(action: { isPresented = nil }) { 106 | Text("Dismiss") 107 | } 108 | } 109 | /** 110 | "Copy Link" is only available for iOS because watchOS doesn't support UIPasteboard.s 111 | */ 112 | #if os(iOS) 113 | ToolbarItem(placement: .bottomBar) { 114 | Button(action: { UIPasteboard.general.url = share.url }) { 115 | Text("Copy Link") 116 | } 117 | } 118 | #endif 119 | } 120 | 121 | private func deleteParticipant(offsets: IndexSet) { 122 | withAnimation { 123 | let ckShareParticipants = offsets.map { participants[$0].ckShareParticipant } 124 | PersistenceController.shared.deleteParticipant(ckShareParticipants, share: share) { share, error in 125 | if error == nil, let updatedShare = share { 126 | participants = updatedShare.participants.filter { $0.role != .owner }.map { Participant($0) } 127 | } 128 | } 129 | } 130 | } 131 | 132 | /** 133 | Ignore the notification in the following cases: 134 | - The notification is not relevant to the private database. 135 | - The notification transaction is not empty. When a share changes, Core Data triggers a store remote change notification with no transaction. 136 | In that case, grab the share with the same title, and use it to update the UI. 137 | */ 138 | private func processStoreChangeNotification(_ notification: Notification) { 139 | guard let storeUUID = notification.userInfo?[UserInfoKey.storeUUID] as? String, 140 | storeUUID == PersistenceController.shared.privatePersistentStore.identifier else { 141 | return 142 | } 143 | guard let transactions = notification.userInfo?[UserInfoKey.transactions] as? [NSPersistentHistoryTransaction], 144 | transactions.isEmpty else { 145 | return 146 | } 147 | if let updatedShare = PersistenceController.shared.share(with: share.title) { 148 | participants = updatedShare.participants.filter { $0.role != .owner }.map { Participant($0) } 149 | 150 | } else { 151 | wasShareDeleted = true 152 | } 153 | } 154 | } 155 | 156 | private struct ParticipantListHeader: View { 157 | @Binding var toggleProgress: Bool 158 | @Binding var participants: [Participant] 159 | var share: CKShare 160 | @State private var emailAddress: String = "" 161 | 162 | var body: some View { 163 | HStack { 164 | TextField( "Email", text: $emailAddress) 165 | Button(action: addParticipant) { 166 | Image(systemName: "plus.circle") 167 | .imageScale(.large) 168 | .font(.system(size: 18)) 169 | } 170 | .frame(width: 20) 171 | .buttonStyle(.plain) 172 | } 173 | .frame(height: 30) 174 | .padding(5) 175 | .background(Color.listHeaderBackground) 176 | } 177 | 178 | /** 179 | If the participant already exists, no need to do anything. 180 | */ 181 | private func addParticipant() { 182 | let isExistingParticipant = share.participants.contains { 183 | $0.userIdentity.lookupInfo?.emailAddress == emailAddress 184 | } 185 | if isExistingParticipant { 186 | return 187 | } 188 | 189 | toggleProgress.toggle() 190 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 191 | PersistenceController.shared.addParticipant(emailAddress: emailAddress, share: share) { share, error in 192 | if error == nil, let updatedShare = share { 193 | DispatchQueue.main.async { 194 | participants = updatedShare.participants.filter { $0.role != .owner }.map { Participant($0) } 195 | emailAddress = "" 196 | toggleProgress.toggle() 197 | } 198 | } 199 | } 200 | } 201 | } 202 | } 203 | 204 | /** 205 | A struct that wraps CKShare.Participant and implements Equatable to trigger SwiftUI update when any of the following state changes: 206 | - userIdentity 207 | - acceptanceStatus 208 | - permission 209 | - role. 210 | */ 211 | private struct Participant: Hashable, Equatable { 212 | let ckShareParticipant: CKShare.Participant 213 | 214 | init(_ ckShareParticipant: CKShare.Participant) { 215 | self.ckShareParticipant = ckShareParticipant 216 | } 217 | 218 | static func == (lhs: Participant, rhs: Participant) -> Bool { 219 | let lhsElement = lhs.ckShareParticipant 220 | let rhsElement = rhs.ckShareParticipant 221 | 222 | if lhsElement.userIdentity != rhsElement.userIdentity || 223 | lhsElement.acceptanceStatus != rhsElement.acceptanceStatus || 224 | lhsElement.permission != rhsElement.permission || 225 | lhsElement.role != rhsElement.role { 226 | return false 227 | } 228 | return true 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /SwiftUI/PhotoContextMenu.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | A SwiftUI view that manages the actions on a photo. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import CoreData 11 | import CloudKit 12 | 13 | struct PhotoContextMenu: View { 14 | @Binding var activeSheet: ActiveSheet? 15 | @Binding var nextSheet: ActiveSheet? 16 | private let photo: Photo 17 | 18 | @State private var isPhotoShared: Bool 19 | @State private var hasAnyShare: Bool 20 | @State private var toggleProgress: Bool = false 21 | 22 | init(activeSheet: Binding, nextSheet: Binding, photo: Photo) { 23 | _activeSheet = activeSheet 24 | _nextSheet = nextSheet 25 | self.photo = photo 26 | isPhotoShared = (PersistenceController.shared.existingShare(photo: photo) != nil) 27 | hasAnyShare = PersistenceController.shared.shareTitles().isEmpty ? false : true 28 | } 29 | 30 | var body: some View { 31 | /** 32 | CloudKit has a limit on how many zones a database can have. To avoid hitting the limit, 33 | apps use the existing share if possible. 34 | */ 35 | ZStack { 36 | ScrollView { 37 | menuButtons() 38 | } 39 | if toggleProgress { 40 | ProgressView() 41 | } 42 | } 43 | .onReceive(NotificationCenter.default.storeDidChangePublisher) { notification in 44 | processStoreChangeNotification(notification) 45 | } 46 | } 47 | 48 | @ViewBuilder 49 | private func menuButtons() -> some View { 50 | /** 51 | For photos in the private database, allow creating a new share or adding to an existing share. 52 | For photos in the shared database, allow managing participation. 53 | */ 54 | if PersistenceController.shared.privatePersistentStore.contains(manageObject: photo) { 55 | Button("Create New Share", action: { 56 | createNewShare(photo: photo) 57 | }) 58 | .disabled(isPhotoShared) 59 | 60 | Button("Add to Existing Share", action: { 61 | activeSheet = .sharePicker(photo) 62 | }) 63 | .disabled(isPhotoShared || !hasAnyShare) 64 | } else { 65 | Button("Manage Participation", action: { 66 | manageParticipation(photo: photo) 67 | }) 68 | } 69 | /** 70 | Tagging and rating. 71 | */ 72 | Divider() 73 | Button("Tag", action: { 74 | activeSheet = .taggingView(photo) 75 | }) 76 | Button("Rate", action: { 77 | activeSheet = .ratingView(photo) 78 | }) 79 | /** 80 | Show the delete button if the user is editing photos and has the permission to delete. 81 | */ 82 | if PersistenceController.shared.persistentContainer.canDeleteRecord(forManagedObjectWith: photo.objectID) { 83 | Divider() 84 | Button("Delete", role: .destructive, action: { 85 | PersistenceController.shared.delete(photo: photo) 86 | activeSheet = nil 87 | }) 88 | } 89 | } 90 | 91 | /** 92 | Use UICloudSharingController to manage the share on iOS. 93 | On watchOS, UICloudSharingController is unavailable, so create the share using Core Data API. 94 | */ 95 | #if os(iOS) 96 | private func createNewShare(photo: Photo) { 97 | PersistenceController.shared.presentCloudSharingController(photo: photo) 98 | } 99 | 100 | private func manageParticipation(photo: Photo) { 101 | PersistenceController.shared.presentCloudSharingController(photo: photo) 102 | } 103 | 104 | #elseif os(watchOS) 105 | /** 106 | Sharing a photo can take a while so dispatch to a global queue so SwiftUI has a chance to show the progress view. 107 | @State variables are thread-safe, so don't need to dispatch back the main queue. 108 | */ 109 | private func createNewShare(photo: Photo) { 110 | toggleProgress.toggle() 111 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 112 | PersistenceController.shared.shareObject(photo, to: nil) { share, error in 113 | toggleProgress.toggle() 114 | if let share = share { 115 | nextSheet = .participantView(share) 116 | activeSheet = nil 117 | } 118 | } 119 | } 120 | } 121 | 122 | private func manageParticipation(photo: Photo) { 123 | nextSheet = .managingSharesView 124 | activeSheet = nil 125 | } 126 | #endif 127 | 128 | /** 129 | Ignore the notification in the following cases: 130 | - It is not relevant to the private database. 131 | - It doesn't have any transaction. When a share changes, Core Data triggers a store remote change notification with no transaction. 132 | */ 133 | private func processStoreChangeNotification(_ notification: Notification) { 134 | guard let storeUUID = notification.userInfo?[UserInfoKey.storeUUID] as? String, 135 | storeUUID == PersistenceController.shared.privatePersistentStore.identifier else { 136 | return 137 | } 138 | guard let transactions = notification.userInfo?[UserInfoKey.transactions] as? [NSPersistentHistoryTransaction], 139 | transactions.isEmpty else { 140 | return 141 | } 142 | isPhotoShared = (PersistenceController.shared.existingShare(photo: photo) != nil) 143 | hasAnyShare = PersistenceController.shared.shareTitles().isEmpty ? false : true 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /SwiftUI/PhotoGridItemView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | A SwiftUI view that manages a grid item. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import CoreData 11 | 12 | struct PhotoGridItemView: View { 13 | /** 14 | This sample doesn't use editButton and editMode because they are unavalable on watchOS. 15 | It uses the delete button in the action list to handle the deletion. 16 | */ 17 | @ObservedObject var photo: Photo 18 | var itemSize: CGSize 19 | private let persistenceController = PersistenceController.shared 20 | 21 | var body: some View { 22 | ZStack(alignment: .topTrailing) { 23 | /** 24 | Show the thumbnail image, or a place holder if the thumbnail data doesn't exist. 25 | */ 26 | if let data = photo.thumbnail?.data, let thumbnail = UIImage(data: data) { 27 | Image(uiImage: thumbnail) 28 | .resizable() 29 | .aspectRatio(contentMode: .fit) 30 | .frame(width: itemSize.width, height: itemSize.height) 31 | } else { 32 | Image(systemName: "questionmark.square.dashed") 33 | .font(.system(size: 30)) 34 | .frame(width: itemSize.width, height: itemSize.height) 35 | } 36 | topLeftButton() 37 | } 38 | .frame(width: itemSize.width, height: itemSize.height) 39 | .background(Color.gridItemBackground) 40 | } 41 | 42 | @ViewBuilder 43 | private func topLeftButton() -> some View { 44 | if persistenceController.sharedPersistentStore.contains(manageObject: photo) { 45 | Image(systemName: "person.2.circle") 46 | .foregroundColor(.gray) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /SwiftUI/PhotoGridView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | A SwiftUI view that manages a photo collection. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import CoreData 11 | import CloudKit 12 | 13 | enum ActiveSheet: Identifiable, Equatable { 14 | #if os(iOS) 15 | case photoPicker // Unavailable on watchOS 16 | #elseif os(watchOS) 17 | case photoContextMenu(Photo) // .contextMenu is deprecated on watchOS so use action list instead. 18 | #endif 19 | case cloudSharingSheet(CKShare) 20 | case managingSharesView 21 | case sharePicker(Photo) 22 | case taggingView(Photo) 23 | case ratingView(Photo) 24 | case participantView(CKShare) 25 | /** 26 | Use the enum member name string as the id for Identifiable. 27 | In the case where an enum has an associated value, use the label, which is equal to the member name string. 28 | */ 29 | var id: String { 30 | let mirror = Mirror(reflecting: self) 31 | if let label = mirror.children.first?.label { 32 | return label 33 | } else { 34 | return "\(self)" 35 | } 36 | } 37 | } 38 | 39 | enum ActiveCover: Identifiable, Equatable { 40 | case fullImageView(Photo) 41 | /** 42 | Use the enum member name string as the id for Identifiable. 43 | In the case where an enum has an associated value, use the label, which is equal to the member name string. 44 | */ 45 | var id: String { 46 | let mirror = Mirror(reflecting: self) 47 | if let label = mirror.children.first?.label { 48 | return label 49 | } else { 50 | return "\(self)" 51 | } 52 | } 53 | } 54 | 55 | struct PhotoGridView: View { 56 | @Environment(\.managedObjectContext) private var viewContext 57 | @FetchRequest(sortDescriptors: [SortDescriptor(\.uniqueName)], 58 | animation: .default 59 | ) private var photos: FetchedResults 60 | 61 | @State private var activeSheet: ActiveSheet? 62 | @State private var activeCover: ActiveCover? 63 | 64 | /** 65 | The next active sheet to present after dismissing the current sheet. 66 | ManagingSharesView uses this variable to switch to UICloudSharingController or participant view. 67 | */ 68 | @State private var nextSheet: ActiveSheet? 69 | 70 | private let persistenceController = PersistenceController.shared 71 | private let kGridCellSize = CGSize(width: 118, height: 118) 72 | 73 | var body: some View { 74 | NavigationView { 75 | VStack { 76 | ScrollView { 77 | if photos.isEmpty { 78 | Text("Tap the add (+) button on the iOS app to add a photo.").padding() 79 | Spacer() 80 | } else { 81 | LazyVGrid(columns: [GridItem(.adaptive(minimum: kGridCellSize.width))]) { 82 | ForEach(photos, id: \.self) { photo in 83 | gridItemView(photo: photo, itemSize: kGridCellSize) 84 | } 85 | } 86 | } 87 | } 88 | } 89 | .toolbar { toolbarItems() } 90 | .navigationTitle("Photos") 91 | .sheet(item: $activeSheet, onDismiss: sheetOnDismiss) { item in 92 | sheetView(with: item) 93 | } 94 | .fullScreenCover(item: $activeCover) { item in 95 | coverView(with: item) 96 | } 97 | 98 | } 99 | .navigationViewStyle(.stack) 100 | .onReceive(NotificationCenter.default.storeDidChangePublisher) { notification in 101 | processStoreChangeNotification(notification) 102 | } 103 | } 104 | 105 | @ViewBuilder 106 | private func gridItemView(photo: Photo, itemSize: CGSize) -> some View { 107 | #if os(iOS) 108 | PhotoGridItemView(photo: photo, itemSize: kGridCellSize) 109 | .contextMenu { 110 | PhotoContextMenu(activeSheet: $activeSheet, nextSheet: $nextSheet, photo: photo) 111 | } 112 | .onTapGesture { 113 | activeCover = .fullImageView(photo) 114 | } 115 | #elseif os(watchOS) 116 | PhotoGridItemView(photo: photo, itemSize: kGridCellSize) 117 | .onTapGesture { 118 | activeSheet = .photoContextMenu(photo) 119 | } 120 | #endif 121 | } 122 | 123 | @ToolbarContentBuilder 124 | private func toolbarItems() -> some ToolbarContent { 125 | #if os(iOS) 126 | ToolbarItem(placement: .navigationBarTrailing) { 127 | Button(action: { activeSheet = .photoPicker }) { 128 | Label("Add Item", systemImage: "plus").labelStyle(.iconOnly) 129 | } 130 | } 131 | ToolbarItem(placement: .bottomBar) { 132 | Button("Manage Shares", action: { 133 | activeSheet = .managingSharesView 134 | }) 135 | } 136 | #elseif os(watchOS) 137 | ToolbarItem(placement: .automatic) { 138 | Button("Manage Shares", action: { activeSheet = .managingSharesView }) 139 | } 140 | #endif 141 | } 142 | 143 | @ViewBuilder 144 | private func sheetView(with item: ActiveSheet) -> some View { 145 | switch item { 146 | #if os(iOS) 147 | case .photoPicker: 148 | PhotoPicker(isPresented: $activeSheet) 149 | #elseif os(watchOS) 150 | case .photoContextMenu(let photo): 151 | PhotoContextMenu(activeSheet: $activeSheet, nextSheet: $nextSheet, photo: photo) 152 | #endif 153 | 154 | case .cloudSharingSheet(_): 155 | // CloudSharingSheet(isPresented: $activeSheet, share: share) // Not used due to Rdar://83684057. 156 | EmptyView() 157 | case .managingSharesView: 158 | ManagingSharesView(isPresented: $activeSheet, nextSheet: $nextSheet) 159 | 160 | case .sharePicker(let photo): 161 | AddToExistingShareView(isPresented: $activeSheet, photo: photo) 162 | 163 | case .taggingView(let photo): 164 | TaggingView(isPresented: $activeSheet, photo: photo) 165 | 166 | case .ratingView(let photo): 167 | RatingView(isPresented: $activeSheet, photo: photo) 168 | 169 | case .participantView(let share): 170 | ParticipantView(isPresented: $activeSheet, share: share) 171 | } 172 | } 173 | 174 | /** 175 | Present the next active sheet if necessary. 176 | Dispatch asynchronously to the next run loop so the presentation occurs after the current sheet's dismissal. 177 | */ 178 | private func sheetOnDismiss() { 179 | guard let nextActiveSheet = nextSheet else { 180 | return 181 | } 182 | switch nextActiveSheet { 183 | case .cloudSharingSheet(let share): 184 | DispatchQueue.main.async { 185 | persistenceController.presentCloudSharingController(share: share) 186 | } 187 | default: 188 | DispatchQueue.main.async { 189 | activeSheet = nextActiveSheet 190 | } 191 | } 192 | nextSheet = nil 193 | } 194 | 195 | @ViewBuilder 196 | private func coverView(with item: ActiveCover) -> some View { 197 | switch item { 198 | case .fullImageView(let photo): 199 | FullImageView(isPresented: $activeCover, photo: photo) 200 | } 201 | } 202 | 203 | /** 204 | Merge the transactions if any. 205 | */ 206 | private func processStoreChangeNotification(_ notification: Notification) { 207 | let transactions = persistenceController.photoTransactions(from: notification) 208 | if !transactions.isEmpty { 209 | persistenceController.mergeTransactions(transactions, to: viewContext) 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /SwiftUI/RatingView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | A SwiftUI view that manages photo rating. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import CoreData 11 | 12 | struct RatingView: View { 13 | @Binding var isPresented: ActiveSheet? 14 | 15 | @State private var toggleProgress: Bool = false 16 | @State private var wasPhotoDeleted = false 17 | private let photo: Photo 18 | private let canUpdate: Bool 19 | 20 | private let fetchRequest: FetchRequest 21 | private var ratings: FetchedResults { 22 | return fetchRequest.wrappedValue 23 | } 24 | 25 | init(isPresented: Binding, photo: Photo) { 26 | _isPresented = isPresented 27 | self.photo = photo 28 | 29 | let nsFetchRequest = Rating.fetchRequest() 30 | nsFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Rating.value, ascending: true)] 31 | nsFetchRequest.predicate = NSPredicate(format: "photo = %@", photo) 32 | fetchRequest = FetchRequest(fetchRequest: nsFetchRequest, animation: .default) 33 | 34 | let container = PersistenceController.shared.persistentContainer 35 | canUpdate = container.canUpdateRecord(forManagedObjectWith: photo.objectID) 36 | } 37 | 38 | var body: some View { 39 | NavigationView { 40 | VStack { 41 | if wasPhotoDeleted { 42 | Text("The photo for rating was deleted remotely.").padding() 43 | Spacer() 44 | } else { 45 | ratingListView() 46 | } 47 | } 48 | .toolbar { 49 | ToolbarItem(placement: .automatic) { 50 | Button("Dismiss", action: { isPresented = nil }) 51 | } 52 | } 53 | .listStyle(PlainListStyle()) 54 | .navigationTitle("Ratings") 55 | } 56 | .onReceive(NotificationCenter.default.storeDidChangePublisher) { _ in 57 | wasPhotoDeleted = photo.isDeleted 58 | } 59 | } 60 | 61 | /** 62 | List -> Section header + section content triggers a strange animation when deleting an item. 63 | Moving the header out (like below) fixes the animation issue, but the toolbar item doesn't work on watchOS. 64 | SectionHeader().padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 0)) 65 | List { 66 | SectionContent() 67 | } 68 | */ 69 | @ViewBuilder 70 | private func ratingListView() -> some View { 71 | ZStack { 72 | List { 73 | Section(header: sectionHeader()) { 74 | sectionContent() 75 | } 76 | } 77 | if toggleProgress { 78 | ProgressView() 79 | } 80 | } 81 | } 82 | 83 | @ViewBuilder 84 | private func sectionHeader() -> some View { 85 | if canUpdate { 86 | RatingListHeader(toggleProgress: $toggleProgress, photo: photo) 87 | } 88 | } 89 | 90 | @ViewBuilder 91 | private func sectionContent() -> some View { 92 | ForEach(ratings, id: \.self) { rating in 93 | HStack { 94 | ForEach(1..<6) { index in 95 | Image(systemName: rating.value >= index ? "star.fill": "star") 96 | .foregroundColor(.gray) 97 | } 98 | } 99 | } 100 | .onDelete(perform: deleteRatings) 101 | } 102 | 103 | private func deleteRatings(offsets: IndexSet) { 104 | if canUpdate { 105 | withAnimation { 106 | let ratingsToBeDeleted = offsets.map { ratings[$0] } 107 | for rating in ratingsToBeDeleted { 108 | PersistenceController.shared.deleteRating(rating) 109 | } 110 | } 111 | } 112 | } 113 | } 114 | 115 | struct RatingListHeader: View { 116 | @Binding var toggleProgress: Bool 117 | let photo: Photo 118 | 119 | @State var ratingValue: Int = 3 120 | 121 | var body: some View { 122 | HStack { 123 | ForEach(1..<6, id: \.self) { index in 124 | Button(action: { ratingValue = index }) { 125 | Image(systemName: ratingValue >= index ? "star.fill": "star") 126 | } 127 | .buttonStyle(.plain) 128 | Spacer().frame(minWidth: 1, idealWidth: 20, maxWidth: 30) 129 | } 130 | Spacer() 131 | Button(action: addRating) { 132 | Image(systemName: "plus.circle") 133 | .imageScale(.large) 134 | .font(.system(size: 18)) 135 | } 136 | .buttonStyle(.plain) 137 | } 138 | .frame(height: 30) 139 | .padding(5) 140 | .background(Color.listHeaderBackground) 141 | } 142 | /** 143 | Toggle the progress view. 144 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1): Allow 0.1 second to show the progress view. 145 | */ 146 | private func addRating() { 147 | toggleProgress.toggle() 148 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 149 | withAnimation { 150 | PersistenceController.shared.addRating(value: Int16(ratingValue), relateTo: photo) 151 | toggleProgress.toggle() 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /SwiftUI/SharePickerView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | A SwiftUI view that picks an existing share. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import CoreData 11 | import CloudKit 12 | 13 | struct SharePickerView: View { 14 | @Binding private var isPresented: ActiveSheet? 15 | @Binding private var selection: String? 16 | 17 | private let actionView: ActionView 18 | @State private var shareTitles = PersistenceController.shared.shareTitles() 19 | 20 | init(isPresented: Binding, selection: Binding, @ViewBuilder actionView: () -> ActionView) { 21 | _isPresented = isPresented 22 | _selection = selection 23 | self.actionView = actionView() 24 | } 25 | 26 | var body: some View { 27 | NavigationView { 28 | VStack { 29 | if shareTitles.isEmpty { 30 | Text("No share exists. Please create a new share for a photo, then try again.").padding() 31 | Spacer() 32 | } else { 33 | Form { 34 | Section(header: Text("Pick a share")) { 35 | ShareListView(selection: $selection, shareTitles: $shareTitles) 36 | } 37 | Section { 38 | actionView 39 | } 40 | } 41 | } 42 | } 43 | .toolbar { 44 | ToolbarItem(placement: .automatic) { 45 | Button("Dismiss", action: { isPresented = nil }) 46 | } 47 | } 48 | .listStyle(PlainListStyle()) 49 | .navigationTitle("Shares") 50 | } 51 | .onReceive(NotificationCenter.default.storeDidChangePublisher) { notification in 52 | processStoreChangeNotification(notification) 53 | } 54 | } 55 | 56 | /** 57 | Update the share list if necessary. Ignore the notification in the following cases: 58 | - The notification is not relevant to the private database. 59 | - The notification transaction is not empty. When a share changes, Core Data triggers a store remote change notification with no transaction. 60 | */ 61 | private func processStoreChangeNotification(_ notification: Notification) { 62 | guard let storeUUID = notification.userInfo?[UserInfoKey.storeUUID] as? String, 63 | storeUUID == PersistenceController.shared.privatePersistentStore.identifier else { 64 | return 65 | } 66 | guard let transactions = notification.userInfo?[UserInfoKey.transactions] as? [NSPersistentHistoryTransaction], 67 | transactions.isEmpty else { 68 | return 69 | } 70 | shareTitles = PersistenceController.shared.shareTitles() 71 | } 72 | 73 | } 74 | 75 | private struct ShareListView: View { 76 | @Binding var selection: String? 77 | @Binding var shareTitles: [String] 78 | 79 | var body: some View { 80 | List(shareTitles, id: \.self) { shareTitle in 81 | HStack { 82 | Text(shareTitle) 83 | Spacer() 84 | if selection == shareTitle { 85 | Image(systemName: "checkmark") 86 | } 87 | } 88 | .contentShape(Rectangle()) 89 | .onTapGesture { 90 | selection = (selection == shareTitle) ? nil : shareTitle 91 | } 92 | } 93 | } 94 | } 95 | 96 | -------------------------------------------------------------------------------- /SwiftUI/SwiftUIHelper.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | Extensions that add convenience methods to SwiftUI. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | extension Color { 13 | static var listHeaderBackground: Color { 14 | #if os(iOS) 15 | return Color(uiColor: .systemGroupedBackground) 16 | #elseif os(watchOS) 17 | return Color(uiColor: .clear) 18 | #endif 19 | } 20 | 21 | static var gridItemBackground: Color { 22 | #if os(iOS) 23 | return Color(.systemGray6) 24 | #elseif os(watchOS) 25 | return Color.gray 26 | #endif 27 | } 28 | } 29 | 30 | extension NotificationCenter { 31 | var storeDidChangePublisher: Publishers.ReceiveOn { 32 | return publisher(for: .cdcksStoreDidChange).receive(on: DispatchQueue.main) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /SwiftUI/TaggingView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | A SwiftUI view that manages photo tagging. 5 | 6 | 7 | */ 8 | 9 | import SwiftUI 10 | import CoreData 11 | 12 | struct TaggingView: View { 13 | @Binding var isPresented: ActiveSheet? 14 | 15 | @State private var filterTagName = "" 16 | @State private var wasPhotoDeleted: Bool 17 | 18 | private let photo: Photo 19 | /** 20 | Retrieving the photo's persistent store (photo.persistentStore) is expensive, so cache it with a member varible 21 | and provide it to FilteredTagList, as FilteredTagList refreshes frequently when the user inputs. 22 | */ 23 | private let affectedStore: NSPersistentStore? 24 | 25 | init(isPresented: Binding, photo: Photo) { 26 | _isPresented = isPresented 27 | self.photo = photo 28 | wasPhotoDeleted = photo.isDeleted 29 | affectedStore = photo.persistentStore 30 | } 31 | 32 | var body: some View { 33 | NavigationView { 34 | VStack { 35 | if wasPhotoDeleted { 36 | Text("The photo was deleted remotely.").padding() 37 | Spacer() 38 | } else { 39 | FilteredTagList(filterTagName: $filterTagName, photo: photo, affectedStore: affectedStore) 40 | } 41 | } 42 | .toolbar { 43 | ToolbarItem(placement: .automatic) { 44 | Button("Dismiss", action: { isPresented = nil }) 45 | } 46 | } 47 | .listStyle(PlainListStyle()) 48 | .navigationTitle("Tags") 49 | } 50 | .onReceive(NotificationCenter.default.storeDidChangePublisher) { _ in 51 | wasPhotoDeleted = photo.isDeleted 52 | } 53 | } 54 | } 55 | 56 | struct FilteredTagList: View { 57 | @Environment(\.managedObjectContext) private var viewContext 58 | @Binding var filterTagName: String 59 | 60 | private let photo: Photo 61 | private let canUpdate: Bool 62 | private let affectedStore: NSPersistentStore? 63 | 64 | @State private var toggleProgress: Bool = false 65 | 66 | private let fetchRequest: FetchRequest 67 | private var tags: [Tag] { 68 | let allTags = Array(fetchRequest.wrappedValue) 69 | return PersistenceController.shared.filterTags(from: allTags, forTagging: photo) 70 | } 71 | 72 | /** 73 | Retrieving the photo's persistent store (photo.persistentStore) is expensive, so relies on the parent view to provide it. 74 | */ 75 | init(filterTagName: Binding, photo: Photo, affectedStore: NSPersistentStore?) { 76 | _filterTagName = filterTagName 77 | self.photo = photo 78 | self.affectedStore = affectedStore 79 | /** 80 | Use a fetch request with a predicate based on the specified filtered tag name, and specify its affected store. 81 | */ 82 | var predicate = NSPredicate(value: true) 83 | if !filterTagName.wrappedValue.isEmpty { 84 | predicate = NSPredicate(format: "name CONTAINS[cd] %@", filterTagName.wrappedValue) 85 | } 86 | let nsFetchRequest = Tag.fetchRequest() 87 | nsFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Tag.name, ascending: true)] 88 | nsFetchRequest.predicate = predicate 89 | if let affectedStore = affectedStore { 90 | nsFetchRequest.affectedStores = [affectedStore] 91 | } 92 | 93 | fetchRequest = FetchRequest(fetchRequest: nsFetchRequest, animation: .default) 94 | 95 | let container = PersistenceController.shared.persistentContainer 96 | canUpdate = container.canUpdateRecord(forManagedObjectWith: photo.objectID) 97 | } 98 | 99 | var body: some View { 100 | ZStack { 101 | /** 102 | List -> Section header + section content triggers a strange animation when deleting an item. 103 | Moving the header out (like below) fixes the animation issue, but the toolbar item doesn't work on watchOS. 104 | SectionHeader().padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 0)) 105 | List { 106 | SectionContent() 107 | } 108 | */ 109 | List { 110 | Section(header: sectionHeader()) { 111 | sectionContent() 112 | } 113 | } 114 | if toggleProgress { 115 | ProgressView() 116 | } 117 | } 118 | } 119 | 120 | @ViewBuilder 121 | private func sectionHeader() -> some View { 122 | if canUpdate { 123 | TagListHeader(toggleProgress: $toggleProgress, filterTagName: $filterTagName, tags: tags, photo: photo) 124 | } 125 | } 126 | 127 | @ViewBuilder 128 | private func sectionContent() -> some View { 129 | ForEach(tags) { tag in 130 | HStack { 131 | Text("\(tag.name!)") 132 | Spacer() 133 | if let photoTags = photo.tags, photoTags.contains(tag) { 134 | Image(systemName: "checkmark") 135 | } 136 | } 137 | .contentShape(Rectangle()) 138 | .onTapGesture { toggleTagging(tag: tag) } 139 | } 140 | .onDelete(perform: deleteTags) 141 | } 142 | 143 | private func deleteTags(offsets: IndexSet) { 144 | if canUpdate { 145 | withAnimation { 146 | let tagsToBeDeleted = offsets.map { tags[$0] } 147 | for tag in tagsToBeDeleted { 148 | PersistenceController.shared.deleteTag(tag) 149 | } 150 | } 151 | } 152 | } 153 | 154 | private func toggleTagging(tag: Tag) { 155 | if canUpdate { 156 | PersistenceController.shared.toggleTagging(photo: photo, tag: tag) 157 | } 158 | } 159 | } 160 | 161 | struct TagListHeader: View { 162 | @Environment(\.managedObjectContext) private var viewContext 163 | @Binding var toggleProgress: Bool 164 | @Binding var filterTagName: String 165 | 166 | private let photo: Photo 167 | private let tags: [Tag] 168 | 169 | init(toggleProgress: Binding, filterTagName: Binding, tags: [Tag], photo: Photo) { 170 | _toggleProgress = toggleProgress 171 | _filterTagName = filterTagName 172 | self.tags = tags 173 | self.photo = photo 174 | } 175 | 176 | var body: some View { 177 | HStack { 178 | TextField( "Name", text: $filterTagName) 179 | 180 | Button(action: addTag) { 181 | Image(systemName: "plus.circle") 182 | .imageScale(.large) 183 | .font(.system(size: 18)) 184 | } 185 | .frame(width: 20) 186 | .buttonStyle(.plain) 187 | .disabled(filterTagName.isEmpty || tags.map { $0.name }.contains(filterTagName)) 188 | } 189 | .frame(height: 30) 190 | .padding(5) 191 | .background(Color.listHeaderBackground) 192 | } 193 | 194 | private func addTag() { 195 | guard !filterTagName.isEmpty else { 196 | return 197 | } 198 | toggleProgress.toggle() 199 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 200 | withAnimation { 201 | PersistenceController.shared.addTag(name: filterTagName, relateTo: photo) 202 | toggleProgress.toggle() 203 | filterTagName = "" 204 | } 205 | } 206 | } 207 | } 208 | --------------------------------------------------------------------------------