diff --git a/CollectionViewSample.xcodeproj/.xcodesamplecode.plist b/CollectionViewSample.xcodeproj/.xcodesamplecode.plist new file mode 100644 index 0000000..5dd5da8 --- /dev/null +++ b/CollectionViewSample.xcodeproj/.xcodesamplecode.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/CollectionViewSample.xcodeproj/project.pbxproj b/CollectionViewSample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a2a077a --- /dev/null +++ b/CollectionViewSample.xcodeproj/project.pbxproj @@ -0,0 +1,662 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 4841A208264357E2002C1504 /* DestinationPostCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4841A207264357E2002C1504 /* DestinationPostCell.swift */; }; + 780E21D926291CEC009E1706 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 780E21D826291CEC009E1706 /* AppDelegate.swift */; }; + 780E21DB26291CEC009E1706 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 780E21DA26291CEC009E1706 /* SceneDelegate.swift */; }; + 780E21DF26291CEE009E1706 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 780E21DE26291CEE009E1706 /* Assets.xcassets */; }; + 780E21E526291CEE009E1706 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 780E21E326291CEE009E1706 /* LaunchScreen.storyboard */; }; + 780E21ED26291D1C009E1706 /* PostGridViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 780E21EC26291D1C009E1706 /* PostGridViewController.swift */; }; + 7824387226571F9A005502E9 /* DestinationPostPropertiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7824387126571F9A005502E9 /* DestinationPostPropertiesView.swift */; }; + 78243887265724DC005502E9 /* UITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78243886265724DC005502E9 /* UITests.swift */; }; + 7850E7622635123E0000AF2A /* URLSession+DownloadTaskPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7850E7612635123E0000AF2A /* URLSession+DownloadTaskPublisher.swift */; }; + 786308482630009100C9FA3A /* DestinationPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 786308472630009100C9FA3A /* DestinationPost.swift */; }; + 7863084B263000B400C9FA3A /* MemoryLimitedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863084A263000B400C9FA3A /* MemoryLimitedCache.swift */; }; + 7863084F263000EE00C9FA3A /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863084E263000EE00C9FA3A /* UnfairLock.swift */; }; + 7869A8A026422578006A78BB /* patagonia.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7869A89F26422578006A78BB /* patagonia.jpg */; }; + 7869A8A226422761006A78BB /* PlaceholderStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7869A8A126422761006A78BB /* PlaceholderStore.swift */; }; + 7869A8A926424EDE006A78BB /* cork.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7869A8A826424EDE006A78BB /* cork.jpg */; }; + 7869A8AB26424EF9006A78BB /* italy.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7869A8AA26424EF9006A78BB /* italy.jpg */; }; + 7869A8B226424F1A006A78BB /* iceland.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7869A8AC26424F19006A78BB /* iceland.jpg */; }; + 7869A8B326424F1A006A78BB /* paris.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7869A8AD26424F19006A78BB /* paris.jpg */; }; + 7869A8B426424F1A006A78BB /* cusco.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7869A8AE26424F19006A78BB /* cusco.jpg */; }; + 7869A8B526424F1A006A78BB /* st-lucia.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7869A8AF26424F19006A78BB /* st-lucia.jpg */; }; + 7869A8B626424F1A006A78BB /* tokyo.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7869A8B026424F1A006A78BB /* tokyo.jpg */; }; + 7869A8B726424F1A006A78BB /* vietnam.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7869A8B126424F1A006A78BB /* vietnam.jpg */; }; + 7869A8B926426915006A78BB /* cambodia.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7869A8B826426915006A78BB /* cambodia.jpg */; }; + 7869A8BB26426921006A78BB /* bali.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7869A8BA26426921006A78BB /* bali.jpg */; }; + 7869A8BD2642692C006A78BB /* new-zealand.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7869A8BC2642692C006A78BB /* new-zealand.jpg */; }; + 786AB522265B2DF300259945 /* FileBasedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 786AB521265B2DF300259945 /* FileBasedCache.swift */; }; + 786AB528265B483100259945 /* Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 786AB527265B483100259945 /* Appearance.swift */; }; + 78983AD22638FEA70058FEF3 /* ModelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78983AD12638FEA70058FEF3 /* ModelStore.swift */; }; + 78983AD42638FFCF0058FEF3 /* AssetStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78983AD32638FFCF0058FEF3 /* AssetStore.swift */; }; + 78983AD62639037A0058FEF3 /* SampleData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78983AD52639037A0058FEF3 /* SampleData.swift */; }; + 78AD5EE026364A6600A4803B /* SectionBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD5EDF26364A6600A4803B /* SectionBackgroundView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 78243888265724DC005502E9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 780E21CD26291CEB009E1706 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 780E21D426291CEB009E1706; + remoteInfo = DestinationUnlocked; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 4081055AB7EA7CB5BDC07482 /* LICENSE.txt */ = {isa = PBXFileReference; includeInIndex = 1; path = LICENSE.txt; sourceTree = ""; }; + 461CECFB0D31BD44BA5DC2A8 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 4841A207264357E2002C1504 /* DestinationPostCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationPostCell.swift; sourceTree = ""; }; + 56337963BA1196A202026205 /* ACKNOWLEDGMENTS.txt */ = {isa = PBXFileReference; includeInIndex = 1; path = ACKNOWLEDGMENTS.txt; sourceTree = ""; }; + 780E21D526291CEC009E1706 /* CollectionViewSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CollectionViewSample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 780E21D826291CEC009E1706 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 780E21DA26291CEC009E1706 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 780E21DE26291CEE009E1706 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 780E21E426291CEE009E1706 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 780E21E626291CEE009E1706 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 780E21EC26291D1C009E1706 /* PostGridViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostGridViewController.swift; sourceTree = ""; }; + 7824387126571F9A005502E9 /* DestinationPostPropertiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationPostPropertiesView.swift; sourceTree = ""; }; + 78243884265724DC005502E9 /* UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 78243886265724DC005502E9 /* UITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITests.swift; sourceTree = ""; }; + 7850E7612635123E0000AF2A /* URLSession+DownloadTaskPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+DownloadTaskPublisher.swift"; sourceTree = ""; }; + 786308472630009100C9FA3A /* DestinationPost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationPost.swift; sourceTree = ""; }; + 7863084A263000B400C9FA3A /* MemoryLimitedCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLimitedCache.swift; sourceTree = ""; }; + 7863084E263000EE00C9FA3A /* UnfairLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnfairLock.swift; sourceTree = ""; }; + 7869A89F26422578006A78BB /* patagonia.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = patagonia.jpg; sourceTree = ""; }; + 7869A8A126422761006A78BB /* PlaceholderStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderStore.swift; sourceTree = ""; }; + 7869A8A826424EDE006A78BB /* cork.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = cork.jpg; sourceTree = ""; }; + 7869A8AA26424EF9006A78BB /* italy.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = italy.jpg; sourceTree = ""; }; + 7869A8AC26424F19006A78BB /* iceland.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = iceland.jpg; sourceTree = ""; }; + 7869A8AD26424F19006A78BB /* paris.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = paris.jpg; sourceTree = ""; }; + 7869A8AE26424F19006A78BB /* cusco.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = cusco.jpg; sourceTree = ""; }; + 7869A8AF26424F19006A78BB /* st-lucia.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "st-lucia.jpg"; sourceTree = ""; }; + 7869A8B026424F1A006A78BB /* tokyo.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = tokyo.jpg; sourceTree = ""; }; + 7869A8B126424F1A006A78BB /* vietnam.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = vietnam.jpg; sourceTree = ""; }; + 7869A8B826426915006A78BB /* cambodia.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = cambodia.jpg; sourceTree = ""; }; + 7869A8BA26426921006A78BB /* bali.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = bali.jpg; sourceTree = ""; }; + 7869A8BC2642692C006A78BB /* new-zealand.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "new-zealand.jpg"; sourceTree = ""; }; + 786AB521265B2DF300259945 /* FileBasedCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileBasedCache.swift; sourceTree = ""; }; + 786AB527265B483100259945 /* Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Appearance.swift; sourceTree = ""; }; + 78983AD12638FEA70058FEF3 /* ModelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelStore.swift; sourceTree = ""; }; + 78983AD32638FFCF0058FEF3 /* AssetStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetStore.swift; sourceTree = ""; }; + 78983AD52639037A0058FEF3 /* SampleData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleData.swift; sourceTree = ""; }; + 78AD5EDF26364A6600A4803B /* SectionBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionBackgroundView.swift; sourceTree = ""; }; + AC2B79F087595B9D32E19980 /* SampleCode.xcconfig */ = {isa = PBXFileReference; name = SampleCode.xcconfig; path = Configuration/SampleCode.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 780E21D226291CEB009E1706 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 78243881265724DC005502E9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 62125B63B73818811A36BC8A /* Configuration */ = { + isa = PBXGroup; + children = ( + AC2B79F087595B9D32E19980 /* SampleCode.xcconfig */, + ); + name = Configuration; + sourceTree = ""; + }; + 780E21CC26291CEB009E1706 = { + isa = PBXGroup; + children = ( + 461CECFB0D31BD44BA5DC2A8 /* README.md */, + 780E21D726291CEC009E1706 /* CollectionViewSample */, + 78243885265724DC005502E9 /* UITests */, + 780E21D626291CEC009E1706 /* Products */, + 62125B63B73818811A36BC8A /* Configuration */, + A11875540FA91ACA764838D9 /* LICENSE */, + ); + sourceTree = ""; + }; + 780E21D626291CEC009E1706 /* Products */ = { + isa = PBXGroup; + children = ( + 780E21D526291CEC009E1706 /* CollectionViewSample.app */, + 78243884265724DC005502E9 /* UITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 780E21D726291CEC009E1706 /* CollectionViewSample */ = { + isa = PBXGroup; + children = ( + 780E21D826291CEC009E1706 /* AppDelegate.swift */, + 780E21DA26291CEC009E1706 /* SceneDelegate.swift */, + 786308472630009100C9FA3A /* DestinationPost.swift */, + 780E21EC26291D1C009E1706 /* PostGridViewController.swift */, + 7869A8A326422C61006A78BB /* Views */, + 78983AD02638FE960058FEF3 /* Stores */, + 7863084D263000E400C9FA3A /* Utilities */, + 7850E763263547B30000AF2A /* Asset Images */, + 780E21E326291CEE009E1706 /* LaunchScreen.storyboard */, + 780E21E626291CEE009E1706 /* Info.plist */, + 780E21DE26291CEE009E1706 /* Assets.xcassets */, + ); + path = CollectionViewSample; + sourceTree = ""; + }; + 78243885265724DC005502E9 /* UITests */ = { + isa = PBXGroup; + children = ( + 78243886265724DC005502E9 /* UITests.swift */, + ); + path = UITests; + sourceTree = ""; + }; + 7850E763263547B30000AF2A /* Asset Images */ = { + isa = PBXGroup; + children = ( + 7869A89F26422578006A78BB /* patagonia.jpg */, + 7869A8A826424EDE006A78BB /* cork.jpg */, + 7869A8AA26424EF9006A78BB /* italy.jpg */, + 7869A8AD26424F19006A78BB /* paris.jpg */, + 7869A8B826426915006A78BB /* cambodia.jpg */, + 7869A8AF26424F19006A78BB /* st-lucia.jpg */, + 7869A8B126424F1A006A78BB /* vietnam.jpg */, + 7869A8BA26426921006A78BB /* bali.jpg */, + 7869A8B026424F1A006A78BB /* tokyo.jpg */, + 7869A8BC2642692C006A78BB /* new-zealand.jpg */, + 7869A8AC26424F19006A78BB /* iceland.jpg */, + 7869A8AE26424F19006A78BB /* cusco.jpg */, + ); + path = "Asset Images"; + sourceTree = ""; + }; + 7863084D263000E400C9FA3A /* Utilities */ = { + isa = PBXGroup; + children = ( + 7863084A263000B400C9FA3A /* MemoryLimitedCache.swift */, + 7863084E263000EE00C9FA3A /* UnfairLock.swift */, + 7850E7612635123E0000AF2A /* URLSession+DownloadTaskPublisher.swift */, + 7869A8A126422761006A78BB /* PlaceholderStore.swift */, + 786AB521265B2DF300259945 /* FileBasedCache.swift */, + 786AB527265B483100259945 /* Appearance.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + 7869A8A326422C61006A78BB /* Views */ = { + isa = PBXGroup; + children = ( + 4841A207264357E2002C1504 /* DestinationPostCell.swift */, + 7824387126571F9A005502E9 /* DestinationPostPropertiesView.swift */, + 78AD5EDF26364A6600A4803B /* SectionBackgroundView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 78983AD02638FE960058FEF3 /* Stores */ = { + isa = PBXGroup; + children = ( + 78983AD12638FEA70058FEF3 /* ModelStore.swift */, + 78983AD32638FFCF0058FEF3 /* AssetStore.swift */, + 78983AD52639037A0058FEF3 /* SampleData.swift */, + ); + path = Stores; + sourceTree = ""; + }; + A11875540FA91ACA764838D9 /* LICENSE */ = { + isa = PBXGroup; + children = ( + 4081055AB7EA7CB5BDC07482 /* LICENSE.txt */, + 56337963BA1196A202026205 /* ACKNOWLEDGMENTS.txt */, + ); + name = LICENSE; + path = LICENSE; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 780E21D426291CEB009E1706 /* CollectionViewSample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 780E21E926291CEE009E1706 /* Build configuration list for PBXNativeTarget "CollectionViewSample" */; + buildPhases = ( + 780E21D126291CEB009E1706 /* Sources */, + 780E21D226291CEB009E1706 /* Frameworks */, + 780E21D326291CEB009E1706 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CollectionViewSample; + productName = CovidRecipes; + productReference = 780E21D526291CEC009E1706 /* CollectionViewSample.app */; + productType = "com.apple.product-type.application"; + }; + 78243883265724DC005502E9 /* UITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7824388A265724DC005502E9 /* Build configuration list for PBXNativeTarget "UITests" */; + buildPhases = ( + 78243880265724DC005502E9 /* Sources */, + 78243881265724DC005502E9 /* Frameworks */, + 78243882265724DC005502E9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 78243889265724DC005502E9 /* PBXTargetDependency */, + ); + name = UITests; + productName = UITests; + productReference = 78243884265724DC005502E9 /* UITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 780E21CD26291CEB009E1706 /* Project object */ = { + isa = PBXProject; + attributes = { + DefaultBuildSystemTypeForWorkspace = Latest; + LastSwiftUpdateCheck = 1300; + LastUpgradeCheck = 1300; + ORGANIZATIONNAME = Apple; + TargetAttributes = { + 780E21D426291CEB009E1706 = { + CreatedOnToolsVersion = 13.0; + }; + 78243883265724DC005502E9 = { + CreatedOnToolsVersion = 13.0; + TestTargetID = 780E21D426291CEB009E1706; + }; + }; + }; + buildConfigurationList = 780E21D026291CEB009E1706 /* Build configuration list for PBXProject "CollectionViewSample" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 780E21CC26291CEB009E1706; + productRefGroup = 780E21D626291CEC009E1706 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 780E21D426291CEB009E1706 /* CollectionViewSample */, + 78243883265724DC005502E9 /* UITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 780E21D326291CEB009E1706 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 780E21E526291CEE009E1706 /* LaunchScreen.storyboard in Resources */, + 7869A8AB26424EF9006A78BB /* italy.jpg in Resources */, + 7869A8B526424F1A006A78BB /* st-lucia.jpg in Resources */, + 7869A8A026422578006A78BB /* patagonia.jpg in Resources */, + 7869A8B226424F1A006A78BB /* iceland.jpg in Resources */, + 7869A8BD2642692C006A78BB /* new-zealand.jpg in Resources */, + 7869A8B726424F1A006A78BB /* vietnam.jpg in Resources */, + 7869A8A926424EDE006A78BB /* cork.jpg in Resources */, + 7869A8B326424F1A006A78BB /* paris.jpg in Resources */, + 7869A8B926426915006A78BB /* cambodia.jpg in Resources */, + 7869A8B626424F1A006A78BB /* tokyo.jpg in Resources */, + 7869A8B426424F1A006A78BB /* cusco.jpg in Resources */, + 780E21DF26291CEE009E1706 /* Assets.xcassets in Resources */, + 7869A8BB26426921006A78BB /* bali.jpg in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 78243882265724DC005502E9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 780E21D126291CEB009E1706 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 786308482630009100C9FA3A /* DestinationPost.swift in Sources */, + 78983AD22638FEA70058FEF3 /* ModelStore.swift in Sources */, + 7863084F263000EE00C9FA3A /* UnfairLock.swift in Sources */, + 780E21D926291CEC009E1706 /* AppDelegate.swift in Sources */, + 78983AD42638FFCF0058FEF3 /* AssetStore.swift in Sources */, + 7863084B263000B400C9FA3A /* MemoryLimitedCache.swift in Sources */, + 786AB522265B2DF300259945 /* FileBasedCache.swift in Sources */, + 7824387226571F9A005502E9 /* DestinationPostPropertiesView.swift in Sources */, + 7850E7622635123E0000AF2A /* URLSession+DownloadTaskPublisher.swift in Sources */, + 780E21DB26291CEC009E1706 /* SceneDelegate.swift in Sources */, + 780E21ED26291D1C009E1706 /* PostGridViewController.swift in Sources */, + 786AB528265B483100259945 /* Appearance.swift in Sources */, + 78AD5EE026364A6600A4803B /* SectionBackgroundView.swift in Sources */, + 78983AD62639037A0058FEF3 /* SampleData.swift in Sources */, + 4841A208264357E2002C1504 /* DestinationPostCell.swift in Sources */, + 7869A8A226422761006A78BB /* PlaceholderStore.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 78243880265724DC005502E9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 78243887265724DC005502E9 /* UITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 78243889265724DC005502E9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 780E21D426291CEB009E1706 /* CollectionViewSample */; + targetProxy = 78243888265724DC005502E9 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 780E21E326291CEE009E1706 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 780E21E426291CEE009E1706 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 780E21E726291CEE009E1706 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AC2B79F087595B9D32E19980 /* SampleCode.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 780E21E826291CEE009E1706 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AC2B79F087595B9D32E19980 /* SampleCode.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 780E21EA26291CEE009E1706 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AC2B79F087595B9D32E19980 /* SampleCode.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CollectionViewSample/Info.plist; + INFOPLIST_KEY_CFBundleExecutable = CollectionViewSample; + INFOPLIST_KEY_CFBundleName = CollectionViewSample; + INFOPLIST_KEY_CFBundleVersion = 1; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.apple-samplecode.com${SAMPLE_CODE_DISAMBIGUATOR}.collection-views-sample"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 780E21EB26291CEE009E1706 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AC2B79F087595B9D32E19980 /* SampleCode.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CollectionViewSample/Info.plist; + INFOPLIST_KEY_CFBundleExecutable = CollectionViewSample; + INFOPLIST_KEY_CFBundleName = CollectionViewSample; + INFOPLIST_KEY_CFBundleVersion = 1; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.apple-samplecode.com${SAMPLE_CODE_DISAMBIGUATOR}.collection-views-sample"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 7824388B265724DC005502E9 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AC2B79F087595B9D32E19980 /* SampleCode.xcconfig */; + buildSettings = { + CODE_SIGN_IDENTITY = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.apple-samplecode.collection-views-sample${SAMPLE_CODE_DISAMBIGUATOR}.UITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = CollectionViewSample; + }; + name = Debug; + }; + 7824388C265724DC005502E9 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AC2B79F087595B9D32E19980 /* SampleCode.xcconfig */; + buildSettings = { + CODE_SIGN_IDENTITY = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.apple-samplecode.collection-views-sample${SAMPLE_CODE_DISAMBIGUATOR}.UITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = CollectionViewSample; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 780E21D026291CEB009E1706 /* Build configuration list for PBXProject "CollectionViewSample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 780E21E726291CEE009E1706 /* Debug */, + 780E21E826291CEE009E1706 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 780E21E926291CEE009E1706 /* Build configuration list for PBXNativeTarget "CollectionViewSample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 780E21EA26291CEE009E1706 /* Debug */, + 780E21EB26291CEE009E1706 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7824388A265724DC005502E9 /* Build configuration list for PBXNativeTarget "UITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7824388B265724DC005502E9 /* Debug */, + 7824388C265724DC005502E9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 780E21CD26291CEB009E1706 /* Project object */; +} diff --git a/CollectionViewSample.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/CollectionViewSample.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..3ddf867 --- /dev/null +++ b/CollectionViewSample.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + BuildSystemType + Latest + + diff --git a/CollectionViewSample/AppDelegate.swift b/CollectionViewSample/AppDelegate.swift new file mode 100644 index 0000000..a7fd4d3 --- /dev/null +++ b/CollectionViewSample/AppDelegate.swift @@ -0,0 +1,24 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +The application delegate. +*/ + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + +} + diff --git a/CollectionViewSample/Asset Images/bali.jpg b/CollectionViewSample/Asset Images/bali.jpg new file mode 100644 index 0000000..a6373d5 Binary files /dev/null and b/CollectionViewSample/Asset Images/bali.jpg differ diff --git a/CollectionViewSample/Asset Images/cambodia.jpg b/CollectionViewSample/Asset Images/cambodia.jpg new file mode 100644 index 0000000..aaea0bf Binary files /dev/null and b/CollectionViewSample/Asset Images/cambodia.jpg differ diff --git a/CollectionViewSample/Asset Images/cork.jpg b/CollectionViewSample/Asset Images/cork.jpg new file mode 100644 index 0000000..a08745d Binary files /dev/null and b/CollectionViewSample/Asset Images/cork.jpg differ diff --git a/CollectionViewSample/Asset Images/cusco.jpg b/CollectionViewSample/Asset Images/cusco.jpg new file mode 100644 index 0000000..9dd6b67 Binary files /dev/null and b/CollectionViewSample/Asset Images/cusco.jpg differ diff --git a/CollectionViewSample/Asset Images/iceland.jpg b/CollectionViewSample/Asset Images/iceland.jpg new file mode 100644 index 0000000..10e186f Binary files /dev/null and b/CollectionViewSample/Asset Images/iceland.jpg differ diff --git a/CollectionViewSample/Asset Images/italy.jpg b/CollectionViewSample/Asset Images/italy.jpg new file mode 100644 index 0000000..609bc0f Binary files /dev/null and b/CollectionViewSample/Asset Images/italy.jpg differ diff --git a/CollectionViewSample/Asset Images/new-zealand.jpg b/CollectionViewSample/Asset Images/new-zealand.jpg new file mode 100644 index 0000000..2cd7ba6 Binary files /dev/null and b/CollectionViewSample/Asset Images/new-zealand.jpg differ diff --git a/CollectionViewSample/Asset Images/paris.jpg b/CollectionViewSample/Asset Images/paris.jpg new file mode 100644 index 0000000..0614ebd Binary files /dev/null and b/CollectionViewSample/Asset Images/paris.jpg differ diff --git a/CollectionViewSample/Asset Images/patagonia.jpg b/CollectionViewSample/Asset Images/patagonia.jpg new file mode 100644 index 0000000..470bc4a Binary files /dev/null and b/CollectionViewSample/Asset Images/patagonia.jpg differ diff --git a/CollectionViewSample/Asset Images/st-lucia.jpg b/CollectionViewSample/Asset Images/st-lucia.jpg new file mode 100644 index 0000000..3884776 Binary files /dev/null and b/CollectionViewSample/Asset Images/st-lucia.jpg differ diff --git a/CollectionViewSample/Asset Images/tokyo.jpg b/CollectionViewSample/Asset Images/tokyo.jpg new file mode 100644 index 0000000..2d4d9dc Binary files /dev/null and b/CollectionViewSample/Asset Images/tokyo.jpg differ diff --git a/CollectionViewSample/Asset Images/vietnam.jpg b/CollectionViewSample/Asset Images/vietnam.jpg new file mode 100644 index 0000000..8ec2743 Binary files /dev/null and b/CollectionViewSample/Asset Images/vietnam.jpg differ diff --git a/CollectionViewSample/Assets.xcassets/AccentColor.colorset/Contents.json b/CollectionViewSample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..28e9dbb --- /dev/null +++ b/CollectionViewSample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "0.598", + "blue" : "0.888", + "green" : "0.912", + "red" : "0.571" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CollectionViewSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/CollectionViewSample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9221b9b --- /dev/null +++ b/CollectionViewSample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CollectionViewSample/Assets.xcassets/Contents.json b/CollectionViewSample/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/CollectionViewSample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CollectionViewSample/Base.lproj/LaunchScreen.storyboard b/CollectionViewSample/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..e5143e5 --- /dev/null +++ b/CollectionViewSample/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CollectionViewSample/DestinationPost.swift b/CollectionViewSample/DestinationPost.swift new file mode 100644 index 0000000..a10fd26 --- /dev/null +++ b/CollectionViewSample/DestinationPost.swift @@ -0,0 +1,54 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +The post object. +*/ + +import UIKit + +struct Section: Identifiable { + + enum Identifier: String, CaseIterable { + case featured = "Featured" + case all = "All" + } + + var id: Identifier + var posts: [DestinationPost.ID] +} + +struct Asset: Identifiable { + static let noAsset: Asset = Asset(id: "none", isPlaceholder: false, image: UIImage(systemName: "airplane.circle.fill")!) + + var id: String + var isPlaceholder: Bool + + var image: UIImage + + func withImage(_ image: UIImage) -> Asset { + var this = self + this.image = image + return this + } +} + +struct DestinationPost: Identifiable { + + var id: String + + var region: String + var subregion: String? + + var numberOfLikes: Int + + var assetID: Asset.ID + + init(id: String, region: String, subregion: String? = nil, numberOfLikes: Int, assetID: Asset.ID) { + self.id = id + self.region = region + self.subregion = subregion + self.numberOfLikes = numberOfLikes + self.assetID = assetID + } +} diff --git a/CollectionViewSample/Info.plist b/CollectionViewSample/Info.plist new file mode 100644 index 0000000..12db648 --- /dev/null +++ b/CollectionViewSample/Info.plist @@ -0,0 +1,38 @@ + + + + + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UISupportedInterfaceOrientations_i~iPhone + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~iPad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/CollectionViewSample/PostGridViewController.swift b/CollectionViewSample/PostGridViewController.swift new file mode 100644 index 0000000..0ce3320 --- /dev/null +++ b/CollectionViewSample/PostGridViewController.swift @@ -0,0 +1,208 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +The grid view controller. +*/ + +import UIKit +import Combine + +class PostGridViewController: UIViewController { + + var dataSource: UICollectionViewDiffableDataSource! = nil + var collectionView: UICollectionView! = nil + + var sectionsStore: AnyModelStore
+ var postsStore: AnyModelStore + var assetsStore: AssetStore + + fileprivate var prefetchingIndexPathOperations = [IndexPath: AnyCancellable]() + fileprivate let sectionHeaderElementKind = "SectionHeader" + + init(sectionsStore: AnyModelStore
, postsStore: AnyModelStore, assetsStore: AssetStore) { + self.sectionsStore = sectionsStore + self.postsStore = postsStore + self.assetsStore = assetsStore + + super.init(nibName: nil, bundle: .main) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + navigationItem.title = "Destinations" + configureHierarchy() + configureDataSource() + setInitialData() + } +} + +extension PostGridViewController { + private func configureHierarchy() { + collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout()) + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + collectionView.backgroundColor = .systemBackground + collectionView.prefetchDataSource = self + view.addSubview(collectionView) + } + + private func configureDataSource() { + let cellRegistration = UICollectionView.CellRegistration { [weak self] cell, indexPath, postID in + guard let self = self else { return } + + let post = self.postsStore.fetchByID(postID) + let asset = self.assetsStore.fetchByID(post.assetID) + + // Retrieve the token that's tracking this asset from either the prefetching operations dictionary + // or just use a token that's already set on the cell, which is the case when a cell is being reconfigured. + var assetToken = self.prefetchingIndexPathOperations.removeValue(forKey: indexPath) ?? cell.assetToken + + // If the asset is a placeholder and there is no token, ask the asset store to load it, reconfiguring + // the cell in its completion handler. + if asset.isPlaceholder && assetToken == nil { + assetToken = self.assetsStore.loadAssetByID(post.assetID) { [weak self] in + self?.setPostNeedsUpdate(postID) + } + } + + cell.assetToken = assetToken + cell.configureFor(post, using: asset) + } + + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { + (collectionView, indexPath, identifier) in + + return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier) + } + + let headerRegistration = createSectionHeaderRegistration() + dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in + return collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath) + } + } + + private func setPostNeedsUpdate(_ id: DestinationPost.ID) { + var snapshot = self.dataSource.snapshot() + snapshot.reconfigureItems([id]) + self.dataSource.apply(snapshot, animatingDifferences: true) + } + + private func setInitialData() { + // Initial data + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections(Section.ID.allCases) + for sectionType in Section.ID.allCases { + let items = self.sectionsStore.fetchByID(sectionType).posts + snapshot.appendItems(items, toSection: sectionType) + } + dataSource.apply(snapshot, animatingDifferences: false) + } +} + +extension PostGridViewController: UICollectionViewDataSourcePrefetching { + func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { + for indexPath in indexPaths { + // Don't start a new prefetching operation if one is in process. + guard prefetchingIndexPathOperations[indexPath] != nil else { + continue + } + guard let destinationID = self.dataSource.itemIdentifier(for: indexPath) else { + continue + } + let destination = self.postsStore.fetchByID(destinationID) + + prefetchingIndexPathOperations[indexPath] = assetsStore.loadAssetByID(destination.assetID) { [weak self] in + // After the asset load completes, trigger a reconfigure for the item so the cell can be updated if + // it is visible. + self?.setPostNeedsUpdate(destinationID) + } + } + } + + func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { + for indexPath in indexPaths { + prefetchingIndexPathOperations.removeValue(forKey: indexPath)?.cancel() + } + } +} + +extension PostGridViewController { + private func createSectionHeaderRegistration() -> UICollectionView.SupplementaryRegistration { + return UICollectionView.SupplementaryRegistration( + elementKind: sectionHeaderElementKind + ) { [weak self] supplementaryView, elementKind, indexPath in + guard let self = self else { return } + + let sectionID = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] + + supplementaryView.configurationUpdateHandler = { supplementaryView, state in + guard let supplementaryCell = supplementaryView as? UICollectionViewListCell else { return } + + var contentConfiguration = UIListContentConfiguration.plainHeader().updated(for: state) + + contentConfiguration.textProperties.font = Appearance.sectionHeaderFont + contentConfiguration.textProperties.color = UIColor.label + + contentConfiguration.text = sectionID.rawValue + + supplementaryCell.contentConfiguration = contentConfiguration + + supplementaryCell.backgroundConfiguration = .clear() + } + } + } + + /// - Tag: Grid + private func createLayout() -> UICollectionViewLayout { + let sectionProvider = { (sectionIndex: Int, + layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(350)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + // If there's space, adapt and go 2-up + peeking 3rd item. + let columnCount = layoutEnvironment.container.effectiveContentSize.width > 500 ? 3 : 1 + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(350)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: columnCount) + group.interItemSpacing = .fixed(20) + + let section = NSCollectionLayoutSection(group: group) + let sectionID = self.dataSource.snapshot().sectionIdentifiers[sectionIndex] + + section.interGroupSpacing = 20 + + if sectionID == .featured { + section.decorationItems = [ + .background(elementKind: "SectionBackground") + ] + + let titleSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(Appearance.sectionHeaderFont.lineHeight)) + let titleSupplementary = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: titleSize, + elementKind: self.sectionHeaderElementKind, + alignment: .top) + + section.boundarySupplementaryItems = [titleSupplementary] + section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 20, bottom: 20, trailing: 20) + } else { + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 20, trailing: 20) + } + return section + } + + let config = UICollectionViewCompositionalLayoutConfiguration() + config.interSectionSpacing = 20 + + let layout = UICollectionViewCompositionalLayout( + sectionProvider: sectionProvider, configuration: config) + + layout.register(SectionBackgroundDecorationView.self, forDecorationViewOfKind: "SectionBackground") + return layout + } +} diff --git a/CollectionViewSample/SceneDelegate.swift b/CollectionViewSample/SceneDelegate.swift new file mode 100644 index 0000000..c3f7593 --- /dev/null +++ b/CollectionViewSample/SceneDelegate.swift @@ -0,0 +1,26 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +The scene delegate. +*/ + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + if let windowScene = scene as? UIWindowScene { + let window = UIWindow(windowScene: windowScene) + window.rootViewController = PostGridViewController(sectionsStore: SampleData.sectionsStore, + postsStore: SampleData.postsStore, + assetsStore: SampleData.assetsStore) + self.window = window + window.makeKeyAndVisible() + } + } + +} + diff --git a/CollectionViewSample/Stores/AssetStore.swift b/CollectionViewSample/Stores/AssetStore.swift new file mode 100644 index 0000000..8f025cd --- /dev/null +++ b/CollectionViewSample/Stores/AssetStore.swift @@ -0,0 +1,183 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +The asset store. +*/ + +import Foundation +import UIKit +import Combine +import UniformTypeIdentifiers + +extension Publisher where Failure == Never { + // Like `replaceNil` but allows for passing a fallback publisher. + func flatMapIfNil(_ downstream: @escaping () -> DS) + -> Publishers.FlatMap, Publishers.SetFailureType> + where DS.Output? == Output { + return self.flatMap { opt in + if let wrapped = opt { + return Just(wrapped).setFailureType(to: DS.Failure.self).eraseToAnyPublisher() + } else { + return downstream().eraseToAnyPublisher() + } + } + } +} + +extension Publisher { + /// Calls a function and returns the same output as upstream. + func withOutput(execute: @escaping (Output) -> Void) -> Publishers.Map { + self.map { output in + execute(output) + return output + } + } +} + +class AssetStore: ModelStore { + static let placeholderFallbackImage = UIImage(systemName: "airplane.circle.fill")! + + private let lock = UnfairLock() + private let preparedImages = MemoryLimitedCache() + + private let localAssets: FileBasedCache + public let placeholderStore: FileBasedCache + private let placeholderQueue = DispatchQueue(label: "com.apple.DestinationUnlocked.placeholderGenerationQueue", + qos: .utility, + attributes: [], + autoreleaseFrequency: .workItem, + target: nil) + + init(fileManager: FileManager = .default) { + let cachesDirectory = try! fileManager.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + .appendingPathComponent("asset-images", isDirectory: true) + let placeholdersDirectory = cachesDirectory.appendingPathComponent("placeholders", isDirectory: true) + + self.localAssets = FileBasedCache(directory: cachesDirectory, fileManager: fileManager) + try! localAssets.createDirectoryIfNeeded() + + self.placeholderStore = PlaceholderStore(directory: placeholdersDirectory, + imageFormat: UTType.jpeg, + fileManager: fileManager) + try! placeholderStore.createDirectoryIfNeeded() + + } + + func fetchByID(_ id: Asset.ID) -> Asset { + if let image = preparedImages.fetchByID(id) { + return image + } + + return placeholderStore.fetchByID(id) ?? Asset(id: id, isPlaceholder: true, image: Self.placeholderFallbackImage) + } + + func loadAssetByID(_ id: Asset.ID, completionHandler: (() -> Void)? = nil) -> AnyCancellable { + if let assertion = preparedImages.takeAssertionForID(id) { + completionHandler?() + return assertion + } + + var prepareImagesCancellation: AnyCancellable? = nil + let downloadCancellation = prepareAssetIfNeeded(id: id) + .sink { _ in + // Take an assertion on the prepared image so that the cell + // can easily access the image when it's reconfigured. + prepareImagesCancellation = self.preparedImages.takeAssertionForID(id) + completionHandler?() + } + + return AnyCancellable { + downloadCancellation.cancel() + prepareImagesCancellation?.cancel() + } + } + + enum AssetError: Swift.Error { + case preparingImageFailed + } + + // Cache the current requests to ensure only one image is processing for + // each asset at a time. + private var requestsCache: [Asset.ID: Publishers.Share>] = [:] + func prepareAssetIfNeeded(id: Asset.ID) -> Publishers.Share> { + // Check the current requests for a match. + if let request = requestsCache[id] { + return request + } + + // Start a new preparation if there is not a cached one. + let publisher = self.prepareAsset(id: id) + .subscribe(on: Self.networkingQueue) + .receive(on: DispatchQueue.main) + .handleEvents(receiveCompletion: { [weak self] _ in + // When the operation is complete, remove the current request. + self?.requestsCache.removeValue(forKey: id) + }) + .assertNoFailure("Downloading Asset (id: \(id)") + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + .share() + requestsCache[id] = publisher + return publisher + } + + func prepareAsset(id: Asset.ID) -> AnyPublisher { + // First check the local cache for the image. + return Just(self.localAssets.fetchByID(id)) + // If there is no local asset, then download it from the server. + .flatMapIfNil { self.makeDownloadRequest(id: id) } + .tryMap { (asset: Asset) -> Asset in + // Begin preparing the image on this queue (see `subscribe(on: DispatchQueue)`) + guard let preparedImage = asset.image.preparingForDisplay() else { + throw AssetError.preparingImageFailed + } + return asset.withImage(preparedImage) + } + // Cache the prepared image. + .withOutput(execute: self.preparedImages.add(asset:)) + .eraseToAnyPublisher() + } + + private func makeDownloadRequest(id: Asset.ID) -> AnyPublisher { + let url = self.localAssets.url(forID: id) + return Self.makeAssetDownload(for: id, to: url) + .tryMap { () -> Asset in + // After download completes, ensure the local cache has + // the asset. + guard let asset = self.localAssets.fetchByID(id) else { + throw CocoaError(.fileNoSuchFile) + } + self.placeholderQueue.async { + // Start generating a placeholder image to use in the future. + self.placeholderStore.addByMovingFromURL(url, forAsset: id) + } + return asset + }.eraseToAnyPublisher() + } + + // MARK: Sample Code Specific + + // Make a queue for fake network download to use. + static let networkingQueue = DispatchQueue(label: "com.example.API.networking-queue", + qos: .userInitiated, + attributes: [], + autoreleaseFrequency: .workItem, + target: nil) + // Start "downloading" the image. + // For the sample code, pull the image from the main Bundle. + // Replace this with an actual download from a server. For an example, see the `DownloadTaskPublisher` included with + // this code. + static func makeAssetDownload(for assetID: Asset.ID, to destinationURL: URL) -> AnyPublisher { + Just(assetID) + .delay(for: .seconds(.random(in: 1..<6)), scheduler: self.networkingQueue) + .share() + .tryMap { assetID in + guard let url = Bundle.main.url(forResource: assetID, withExtension: "jpg") else { + throw CocoaError(.fileNoSuchFile) + } + try? FileManager.default.copyItem(at: url, to: destinationURL) + } + .eraseToAnyPublisher() + } +} diff --git a/CollectionViewSample/Stores/ModelStore.swift b/CollectionViewSample/Stores/ModelStore.swift new file mode 100644 index 0000000..86200ea --- /dev/null +++ b/CollectionViewSample/Stores/ModelStore.swift @@ -0,0 +1,37 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +The model store. +*/ + +import Foundation + +protocol ModelStore { + associatedtype Model: Identifiable + + func fetchByID(_ id: Model.ID) -> Model +} + +class AnyModelStore: ModelStore { + + private var models = [Model.ID: Model]() + + init(_ models: [Model]) { + self.models = models.groupingByUniqueID() + } + + func fetchByID(_ id: Model.ID) -> Model { + return self.models[id]! + } +} + +extension Sequence where Element: Identifiable { + func groupingByID() -> [Element.ID: [Element]] { + return Dictionary(grouping: self, by: { $0.id }) + } + + func groupingByUniqueID() -> [Element.ID: Element] { + return Dictionary(uniqueKeysWithValues: self.map { ($0.id, $0) }) + } +} diff --git a/CollectionViewSample/Stores/SampleData.swift b/CollectionViewSample/Stores/SampleData.swift new file mode 100644 index 0000000..60da797 --- /dev/null +++ b/CollectionViewSample/Stores/SampleData.swift @@ -0,0 +1,33 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +The sample data. +*/ + +import Foundation + +struct SampleData { + static let sectionsStore = AnyModelStore([ + Section(id: .featured, posts: ["a", "b"]), + Section(id: .all, posts: ["c", "d", "e", "f", "g", "h", "i", "j", "k", "l"]) + ]) + + static let postsStore = AnyModelStore([ + DestinationPost(id: "a", region: "Peru", subregion: "Cusco", numberOfLikes: 31, assetID: "cusco"), + DestinationPost(id: "b", region: "Caribbean", subregion: "Saint Lucia", numberOfLikes: 25, assetID: "st-lucia"), + + DestinationPost(id: "c", region: "Japan", subregion: "Tokyo", numberOfLikes: 16, assetID: "tokyo"), + DestinationPost(id: "d", region: "Iceland", subregion: "Reykjavík", numberOfLikes: 9, assetID: "iceland"), + DestinationPost(id: "e", region: "France", subregion: "Paris", numberOfLikes: 14, assetID: "paris"), + DestinationPost(id: "f", region: "Italy", subregion: "Capri", numberOfLikes: 11, assetID: "italy"), + DestinationPost(id: "g", region: "Viet Nam", subregion: "Cat Ba", numberOfLikes: 17, assetID: "vietnam"), + DestinationPost(id: "h", region: "New Zealand", subregion: nil, numberOfLikes: 5, assetID: "new-zealand"), + DestinationPost(id: "i", region: "Indonesia", subregion: "Bali", numberOfLikes: 10, assetID: "bali"), + DestinationPost(id: "j", region: "Ireland", subregion: "Cork", numberOfLikes: 21, assetID: "cork"), + DestinationPost(id: "k", region: "Chile", subregion: "Patagonia", numberOfLikes: 9, assetID: "patagonia"), + DestinationPost(id: "l", region: "Cambodia", subregion: nil, numberOfLikes: 14, assetID: "cambodia") + ]) + + static let assetsStore = AssetStore() +} diff --git a/CollectionViewSample/Utilities/Appearance.swift b/CollectionViewSample/Utilities/Appearance.swift new file mode 100644 index 0000000..a1d4d27 --- /dev/null +++ b/CollectionViewSample/Utilities/Appearance.swift @@ -0,0 +1,40 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +Defines some appearance constants. +*/ + +import Foundation +import UIKit +struct Appearance { + static let sectionHeaderFont: UIFont = { + let boldFontDescriptor = UIFontDescriptor + .preferredFontDescriptor(withTextStyle: .largeTitle) + .withSymbolicTraits(.traitBold)! + return UIFont(descriptor: boldFontDescriptor, size: 0) + }() + + static let postImageHeightRatio = 0.8 + + static let titleFont: UIFont = { + let descriptor = UIFontDescriptor + .preferredFontDescriptor(withTextStyle: .title1) + .withSymbolicTraits(.traitBold)! + return UIFont(descriptor: descriptor, size: 0) + }() + + static let subtitleFont: UIFont = { + let descriptor = UIFontDescriptor + .preferredFontDescriptor(withTextStyle: .title2) + .withSymbolicTraits(.traitBold)! + return UIFont(descriptor: descriptor, size: 0) + }() + + static let likeCountFont: UIFont = { + let descriptor = UIFontDescriptor + .preferredFontDescriptor(withTextStyle: .subheadline) + .withDesign(.monospaced)! + return UIFont(descriptor: descriptor, size: 0) + }() +} diff --git a/CollectionViewSample/Utilities/FileBasedCache.swift b/CollectionViewSample/Utilities/FileBasedCache.swift new file mode 100644 index 0000000..3726c43 --- /dev/null +++ b/CollectionViewSample/Utilities/FileBasedCache.swift @@ -0,0 +1,71 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +A cache that saves things in local files. +*/ + +import Foundation +import UIKit +import os +import UniformTypeIdentifiers + +extension Logger { + static let disabled = Logger(.disabled) + static let `default` = Logger(.default) +} + +class FileBasedCache: Cache { + var fileManager: FileManager + let imageFormat: UTType + let directory: URL + var logger: Logger + var signposter: OSSignposter + + var isPlaceholderStore: Bool = false + + init(directory: URL, + imageFormat: UTType = .jpeg, + logger: Logger = .default, + fileManager: FileManager = .default) { + self.directory = directory + self.logger = logger + self.signposter = OSSignposter(logger: logger) + self.imageFormat = imageFormat + self.fileManager = fileManager + } + + func createDirectoryIfNeeded() throws { + try fileManager.createDirectory(at: self.directory, + withIntermediateDirectories: true, + attributes: nil) + } + + func fetchByID(_ id: Asset.ID) -> Asset? { + guard let image = UIImage(contentsOfFile: url(forID: id).path) else { + return nil + } + return Asset(id: id, isPlaceholder: isPlaceholderStore, image: image) + } + + func addByMovingFromURL(_ url: URL, forAsset id: Asset.ID) { + precondition(isMatchingImageType(url), "Asset at \(url) does not conform to \(imageFormat)") + try? fileManager.moveItem(at: url, to: self.url(forID: id)) + } + + func clear() throws { + try fileManager.removeItem(at: self.directory) + try createDirectoryIfNeeded() + } + + func isMatchingImageType(_ url: URL) -> Bool { + return UTType( + filenameExtension: url.pathExtension, + conformingTo: imageFormat + ) != nil + } + + func url(forID id: Asset.ID) -> URL { + return directory.appendingPathComponent(id).appendingPathExtension(for: imageFormat) + } +} diff --git a/CollectionViewSample/Utilities/MemoryLimitedCache.swift b/CollectionViewSample/Utilities/MemoryLimitedCache.swift new file mode 100644 index 0000000..26caf0c --- /dev/null +++ b/CollectionViewSample/Utilities/MemoryLimitedCache.swift @@ -0,0 +1,145 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +An in-memory cache. +*/ + +import UIKit +import Combine +import UniformTypeIdentifiers +import os + +protocol Cache { + associatedtype Model: Identifiable + + func fetchByID(_ id: Model.ID) -> Model? +} + +extension Measurement where UnitType: Dimension { + static var zero: Self { + return .init(value: 0.0, unit: UnitType.baseUnit()) + } +} + +// Cache that tries to stay under a fixed memory limit. +class MemoryLimitedCache: Cache { + + let memoryLimit: Measurement + private(set) var currentMemoryUsage = Measurement.zero + + private let accessLock = UnfairLock() + private var assets: [Asset.ID: Asset] = [:] // GuardedBy(accessLock) + private var protectedAssetIDs: [Asset.ID: Int] = [:] // GuardedBy(accessLock) + private var cancellation = [AnyCancellable]() + + private let logger: Logger + private let signposter: OSSignposter + + // The default memory limit is 320 MB, which is very large. This is because there are + // incredibly large assets for demonstration purposes. Rely on the Image resizing APIs + // to scale images to the size needed and to save memory. + init(limit: Measurement = .init(value: 320, unit: .megabytes), + logger: Logger = .default, + signposter: OSSignposter? = nil) { + self.memoryLimit = limit + self.logger = logger + self.signposter = signposter ?? OSSignposter(logger: logger) + + // Purge everything when app is under memory pressure. + NotificationCenter.default + .publisher(for: UIApplication.didReceiveMemoryWarningNotification) + .sink { [weak self] _ in self?.purge() } + .store(in: &cancellation) + } + + func fetchByID(_ id: Asset.ID) -> Asset? { + return accessLock.withLock { self.assets[id] } + } + + // Asset with `id` will have its retention priority increased by 1 until `.cancel` is called. + // - Returns: Cancellable which will decrease the priority by 1. + func takeAssertionForID(_ id: Asset.ID) -> AnyCancellable? { + let lock = self.accessLock.acquire() + defer { lock.cancel() } + + guard self.assets[id] != nil else { return nil } + + let signpostID = signposter.makeSignpostID() + let interval = signposter.beginInterval("Assertion", id: signpostID, "id=\(id, attributes: "name=id")") + + protectedAssetIDs[id, default: 0] += 1 + return AnyCancellable { [weak self] in + guard let self = self else { return } + self.accessLock.withLock { + guard var retainCount = self.protectedAssetIDs[id] else { return } + retainCount -= 1 + // Passing nil to remove the id from `protectedAssetID`. + self.protectedAssetIDs[id] = retainCount > 0 ? retainCount : nil + } + self.signposter.endInterval("Assertion", interval) + } + } + + func add(asset: Asset) { + guard !asset.isPlaceholder else { + logger.notice("Placeholder (\(asset.id)) was sent to MemoryLimitedCache - Rejecting.") + return + } + let estimatedMemory = self.estimatedMemory(of: asset) + signposter.emitEvent("AddAsset", "assetID=\(asset.id) memoryUsage=\(estimatedMemory)") + accessLock.withLock { + // Remove the current asset if it exists. + if let currentAsset = assets.removeValue(forKey: asset.id) { + currentMemoryUsage = currentMemoryUsage - self.estimatedMemory(of: currentAsset) + } + if (currentMemoryUsage + estimatedMemory) >= memoryLimit { + self._locked_purge(atLeast: estimatedMemory) + } + assets[asset.id] = asset + + currentMemoryUsage = currentMemoryUsage + estimatedMemory + } + } + + func purge(atLeast amount: Measurement? = nil) { + accessLock.withLock { + _locked_purge(atLeast: amount) + } + } + + /// Estimates the cost of an image as `Width*Height*Channels`. + /// - note: Image memory cost can vary depending on the image and only *preparedImages* are safe + /// to estimate this way. + func estimatedMemory(of asset: Asset) -> Measurement { + let image = asset.image + return Measurement(value: Double(image.size.width * image.size.height * 3), unit: UnitInformationStorage.bytes) + } + + private func _locked_purge(atLeast amount: Measurement?) { + let interval = signposter.beginInterval("Purge", "amount=\(amount?.formatted() ?? "", attributes: "name=amount")") + defer { signposter.endInterval("Purge", interval, "newCount=\(self.assets.count)") } + + guard var amountToGo = amount, amountToGo < currentMemoryUsage else { + assets.removeAll() + logger.log("Removed all. Saving \(self.currentMemoryUsage)") + currentMemoryUsage = .zero + return + } + + // Delete non-protected IDs first and then go through the others. + let weightedKeys = self.assets.keys.map { ($0, self.protectedAssetIDs[$0, default: 0]) }.sorted(by: { $0.1 < $1.1 }).map { $0.0 } + for id in weightedKeys { + guard amountToGo > .zero else { break } + + let asset = self.assets.removeValue(forKey: id)! + + let estimatedMemory = self.estimatedMemory(of: asset) + logger.trace("Removed \(id) saving \(estimatedMemory)") + + amountToGo = amountToGo - estimatedMemory + currentMemoryUsage = currentMemoryUsage - estimatedMemory + } + } +} + diff --git a/CollectionViewSample/Utilities/PlaceholderStore.swift b/CollectionViewSample/Utilities/PlaceholderStore.swift new file mode 100644 index 0000000..3e9209f --- /dev/null +++ b/CollectionViewSample/Utilities/PlaceholderStore.swift @@ -0,0 +1,87 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +A store that generates placeholder images of larger assets. +*/ + +import Foundation +import UniformTypeIdentifiers +import CoreGraphics +import os +import ImageIO + +class PlaceholderStore: FileBasedCache { + override init(directory: URL, + imageFormat: UTType = .jpeg, + logger: Logger = .default, + fileManager: FileManager = .default) { + super.init(directory: directory, imageFormat: imageFormat, logger: logger, fileManager: fileManager) + self.isPlaceholderStore = true + } + + override func addByMovingFromURL(_ url: URL, forAsset id: Asset.ID) { + self.downsample(from: url, to: self.url(forID: id)) + } + + func downsample(from sourceURL: URL, to destinationURL: URL) { + signposter.withIntervalSignpost("PlaceholderDownsample", id: signposter.makeSignpostID()) { + let readOptions: [CFString: Any] = [ + // Save the new image and don't retain any extra memory. + kCGImageSourceShouldCache: false + ] + + guard let source = CGImageSourceCreateWithURL(sourceURL as CFURL, readOptions as CFDictionary) + else { + self.logger.error("Could not make image source from \(sourceURL, privacy: .public)") + return + } + + let imageSize = sizeFromSource(source) + + let writeOptions = [ + // When the image data is read, only read the data that is needed. + kCGImageSourceSubsampleFactor: subsampleFactor(maxPixelSize: 100, imageSize: imageSize), + // When data is written, ensure the longest dimension is 100px. + kCGImageDestinationImageMaxPixelSize: 100, + // Compress the image as much as possible. + kCGImageDestinationLossyCompressionQuality: 0.0 + // Merge the readOptions since `CGImageDestinationAddImageFromSource` is used + // which both reads (makes a CGImage) and writes (saves to CGDestination). + ].merging(readOptions, uniquingKeysWith: { aSide, bSide in aSide }) + + guard let destination = CGImageDestinationCreateWithURL(destinationURL as CFURL, + imageFormat.identifier as CFString, + 1, + writeOptions as CFDictionary) + else { + self.logger.error("Could not make image destination for \(destinationURL, privacy: .public)") + return + } + CGImageDestinationAddImageFromSource(destination, source, 0, writeOptions as CFDictionary) + CGImageDestinationFinalize(destination) + } + } + + func subsampleFactor(maxPixelSize: Int, imageSize: CGSize) -> Int { + let largerDimensionMultiple = max(imageSize.width, imageSize.height) / CGFloat(maxPixelSize) + let subsampleFactor = floor(log2(largerDimensionMultiple)) + return Int(subsampleFactor.rounded(.towardZero)) + } + + func sizeFromSource(_ source: CGImageSource) -> CGSize { + let options: [CFString: Any] = [ + // Get the image's size without reading it into memory. + kCGImageSourceShouldCache: false + ] + + let properties = CGImageSourceCopyPropertiesAtIndex( + source, 0, options as NSDictionary + ) as? [String: CFNumber] + + let width = properties?[kCGImagePropertyPixelWidth as String] ?? 1 as CFNumber + let height = properties?[kCGImagePropertyPixelHeight as String] ?? 1 as CFNumber + + return CGSize(width: Int(truncating: width), height: Int(truncating: height)) + } +} diff --git a/CollectionViewSample/Utilities/URLSession+DownloadTaskPublisher.swift b/CollectionViewSample/Utilities/URLSession+DownloadTaskPublisher.swift new file mode 100644 index 0000000..fae4412 --- /dev/null +++ b/CollectionViewSample/Utilities/URLSession+DownloadTaskPublisher.swift @@ -0,0 +1,125 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +A URL session data task publisher. +*/ + +import Foundation +import Combine + +extension URLSession { + public func downloadTaskPublisher(for request: URLRequest) -> DownloadTaskPublisher { + return DownloadTaskPublisher(request: request, session: self) + } + + public func downloadTaskPublisher(for url: URL) -> DownloadTaskPublisher { + self.downloadTaskPublisher(for: URLRequest(url: url)) + } +} + +public struct DownloadTaskPublisher: Publisher { + + public typealias Output = (url: URL, response: URLResponse) + public typealias Failure = URLError + + public let request: URLRequest + public let session: URLSession + + public init(request: URLRequest, session: URLSession) { + self.request = request + self.session = session + } + + public func receive(subscriber: S) where S: Subscriber, + DownloadTaskPublisher.Failure == S.Failure, + DownloadTaskPublisher.Output == S.Input { + let subscription = DownloadTaskSubscription(parent: self, downstream: subscriber) + subscriber.receive(subscription: subscription) + } + + private typealias Parent = DownloadTaskPublisher + private final class DownloadTaskSubscription: Subscription + where + Downstream.Input == Parent.Output, + Downstream.Failure == Parent.Failure { + + private let lock: UnfairLock + private var parent: Parent? + private var downstream: Downstream? + private var demand: Subscribers.Demand + private var task: URLSessionDownloadTask! + + init(parent: Parent, downstream: Downstream) { + self.lock = UnfairLock() + self.parent = parent + self.downstream = downstream + self.demand = .max(0) + self.task = parent.session.downloadTask(with: parent.request, completionHandler: handleResponse(url:response:error:)) + + } + + func request(_ demandingSubscribers: Subscribers.Demand) { + precondition(demandingSubscribers > 0, "Invalid request of zero demand") + + let lockAssertion = lock.acquire() + guard parent != nil else { + // The lock has already been canceled so bail. + lockAssertion.cancel() + return + } + + self.demand += demandingSubscribers + guard let task = self.task else { + lockAssertion.cancel() + return + } + lockAssertion.cancel() + + task.resume() + } + + private func handleResponse(url: URL?, response: URLResponse?, error: Error?) { + let lockAssertion = lock.acquire() + guard demand > 0, + parent != nil, + let downstreamResponse = downstream + else { + lockAssertion.cancel() + return + } + + parent = nil + downstream = nil + + // Clear demand since this is a single shot shape. + demand = .max(0) + task = nil + lockAssertion.cancel() + + if let url = url, let response = response, error == nil { + _ = downstreamResponse.receive((url, response)) + downstreamResponse.receive(completion: .finished) + } else { + let urlError = error as? URLError ?? URLError(.unknown) + downstreamResponse.receive(completion: .failure(urlError)) + } + } + + func cancel() { + let lockAssertion = lock.acquire() + guard parent != nil else { + lockAssertion.cancel() + return + } + parent = nil + downstream = nil + demand = .max(0) + let task = self.task + self.task = nil + lockAssertion.cancel() + task?.cancel() + } + } + +} diff --git a/CollectionViewSample/Utilities/UnfairLock.swift b/CollectionViewSample/Utilities/UnfairLock.swift new file mode 100644 index 0000000..f834b96 --- /dev/null +++ b/CollectionViewSample/Utilities/UnfairLock.swift @@ -0,0 +1,70 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +A custom unfair lock implementation. +*/ + +import Foundation +import UIKit +import Combine + +final class UnfairLock { + @usableFromInline let lock: UnsafeMutablePointer + + public init() { + lock = .allocate(capacity: 1) + lock.initialize(to: os_unfair_lock()) + } + + deinit { + lock.deallocate() + } + + @inlinable + @inline(__always) + func withLock(body: () throws -> Result) rethrows -> Result { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return try body() + } + + @inlinable + @inline(__always) + func withLock(body: () -> Void) { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + body() + } + + // Assert that the current thread owns the lock. + @inlinable + @inline(__always) + public func assertOwner() { + os_unfair_lock_assert_owner(lock) + } + + // Assert that the current thread does not own the lock. + @inlinable + @inline(__always) + public func assertNotOwner() { + os_unfair_lock_assert_not_owner(lock) + } + + private final class LockAssertion: Cancellable { + private var _owner: UnfairLock + + init(owner: UnfairLock) { + _owner = owner + os_unfair_lock_lock(owner.lock) + } + + __consuming func cancel() { + os_unfair_lock_unlock(_owner.lock) + } + } + + func acquire() -> Cancellable { + return LockAssertion(owner: self) + } +} diff --git a/CollectionViewSample/Views/DestinationPostCell.swift b/CollectionViewSample/Views/DestinationPostCell.swift new file mode 100644 index 0000000..c74b6ef --- /dev/null +++ b/CollectionViewSample/Views/DestinationPostCell.swift @@ -0,0 +1,126 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +The cell that displays destinations. +*/ + +import UIKit +import Combine + +extension CACornerMask { + static func alongEdge(_ edge: CGRectEdge) -> CACornerMask { + switch edge { + case .maxXEdge: return [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] + case .maxYEdge: return [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + case .minXEdge: return [.layerMinXMinYCorner, .layerMinXMaxYCorner] + case .minYEdge: return [.layerMinXMinYCorner, .layerMaxXMinYCorner] + } + } +} + +class DestinationPostCell: UICollectionViewCell { + + // Set on each cell to track asset use. The cell will call cancel on the token when + // preparing for reuse or when it deinitializes. + public var assetToken: Cancellable? + + private let imageView = UIImageView() + private let propertiesView = DestinationPostPropertiesView() + + private var validLayoutBounds: CGRect? = nil + private var validSizeThatFits: CGSize? = nil + + override init(frame: CGRect) { + super.init(frame: frame) + + imageView.contentMode = .scaleAspectFill + imageView.backgroundColor = .secondarySystemBackground + // Clips to Bounds because images draw outside their bounds + // when a corner radius is set. + imageView.clipsToBounds = true + + contentView.addSubview(imageView) + contentView.addSubview(propertiesView) + + contentView.layer.cornerCurve = .continuous + contentView.layer.cornerRadius = 12.0 + self.pushCornerPropertiesToChildren() + + layer.shadowOpacity = 0.2 + layer.shadowRadius = 6.0 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + assetToken?.cancel() + assetToken = nil + } + + public func configureFor(_ post: DestinationPost, using asset: Asset) { + imageView.image = asset.image + propertiesView.post = post + } + + override func prepareForReuse() { + super.prepareForReuse() + assetToken?.cancel() + assetToken = nil + } + + override func layoutSubviews() { + super.layoutSubviews() + + let (imageFrame, propertiesFrame) = bounds.divided(atDistance: imageHeight(at: bounds.size), + from: .minYEdge) + + imageView.frame = imageFrame + propertiesView.frame = propertiesFrame + + // Setting a shadow path avoids costly offscreen passes. + layer.shadowPath = UIBezierPath(roundedRect: bounds, + cornerRadius: contentView.layer.cornerRadius).cgPath + previousBounds = self.bounds.size + } + + // Override `sizeThatFits` so it can ask the `propertiesView`. + override func sizeThatFits(_ size: CGSize) -> CGSize { + var height = self.imageHeight(at: size) + height += propertiesView.sizeThatFits(size).height + + return CGSize(width: size.width, height: height) + } + + // A cache value to ensure it doesn't relay out. + private var previousBounds: CGSize = .zero + + override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + // If it's already rendered at the proposed size it can just return. + if previousBounds == layoutAttributes.size && !propertiesView.needsRelayout { + return layoutAttributes + } else { + // This will call `sizeThatFits`. + return super.preferredLayoutAttributesFitting(layoutAttributes) + } + } + + // Applying the corner radius to the children means not having to set `clipsToBounds` which + // saves from having extra offscreen passes. + private func pushCornerPropertiesToChildren() { + imageView.layer.maskedCorners = contentView.layer.maskedCorners.intersection(.alongEdge(.minYEdge)) + propertiesView.layer.maskedCorners = contentView.layer.maskedCorners.intersection(.alongEdge(.maxYEdge)) + + imageView.layer.cornerRadius = contentView.layer.cornerRadius + propertiesView.layer.cornerRadius = contentView.layer.cornerRadius + + imageView.layer.cornerCurve = contentView.layer.cornerCurve + propertiesView.layer.cornerCurve = contentView.layer.cornerCurve + } + + private func imageHeight(at size: CGSize) -> CGFloat { + return ceil(size.width * Appearance.postImageHeightRatio) + } +} diff --git a/CollectionViewSample/Views/DestinationPostPropertiesView.swift b/CollectionViewSample/Views/DestinationPostPropertiesView.swift new file mode 100644 index 0000000..bc41776 --- /dev/null +++ b/CollectionViewSample/Views/DestinationPostPropertiesView.swift @@ -0,0 +1,109 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +The destination post properties view. +*/ +import UIKit + +class DestinationPostPropertiesView: UIView { + + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let likeCountLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .secondarySystemBackground + + titleLabel.font = Appearance.titleFont + titleLabel.textColor = UIColor.label + titleLabel.adjustsFontForContentSizeCategory = true + + subtitleLabel.font = Appearance.subtitleFont + subtitleLabel.textColor = UIColor.secondaryLabel + subtitleLabel.adjustsFontForContentSizeCategory = true + + likeCountLabel.font = Appearance.likeCountFont + likeCountLabel.textColor = .secondaryLabel + likeCountLabel.adjustsFontForContentSizeCategory = true + + addSubview(titleLabel) + addSubview(subtitleLabel) + addSubview(likeCountLabel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + var needsRelayout: Bool = true + var post: DestinationPost? { + didSet { + let header = self.headerValues(for: post) + if header != self.headerValues(for: oldValue) { + titleLabel.text = header.title + subtitleLabel.text = header.subtitle + // Only invalidate our current size + // when the post changes. + needsRelayout = true + } + + let numberOfLikes = post?.numberOfLikes ?? 0 + likeCountLabel.text = "\(numberOfLikes) likes" + } + } + + private var previousBounds: CGSize = .zero + override func layoutSubviews() { + super.layoutSubviews() + + needsRelayout = false + var layoutBounds = bounds.inset(by: layoutMargins) + + // Fills the remaining height with the `sizeThatFits` + // the `view` + func layout(view: UIView) { + let fittingSize = CGSize(width: layoutBounds.width, height: UILabel.noIntrinsicMetric) + let size = view.sizeThatFits(fittingSize) + (view.frame, layoutBounds) = layoutBounds.divided(atDistance: size.height, from: .minYEdge) + } + + layout(view: titleLabel) + + if subtitleLabel.text != nil { + layout(view: subtitleLabel) + } + + if likeCountLabel.text != nil { + layout(view: likeCountLabel) + } + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + let layoutMargin = self.layoutMargins + let fittingSize = CGSize(width: size.width - layoutMargins.left - layoutMargins.right, height: UILabel.noIntrinsicMetric) + + var height = layoutMargin.top + layoutMargin.bottom + height += titleLabel.sizeThatFits(fittingSize).height + if subtitleLabel.text != nil { + height += subtitleLabel.sizeThatFits(fittingSize).height + } + if likeCountLabel.text != nil { + height += likeCountLabel.sizeThatFits(fittingSize).height + } + + return CGSize(width: size.width, height: height) + } + + private func headerValues(for post: DestinationPost?) -> (title: String?, subtitle: String?) { + guard let post = post else { return (nil, nil) } + if let subregion = post.subregion { + return (subregion, post.region) + } else { + return (post.region, nil) + } + } +} + diff --git a/CollectionViewSample/Views/SectionBackgroundView.swift b/CollectionViewSample/Views/SectionBackgroundView.swift new file mode 100644 index 0000000..70781f2 --- /dev/null +++ b/CollectionViewSample/Views/SectionBackgroundView.swift @@ -0,0 +1,33 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +The section background view. +*/ + +import UIKit + +class SectionBackgroundDecorationView: UICollectionReusableView { + private var gradientLayer = CAGradientLayer() + override init(frame: CGRect) { + super.init(frame: frame) + + configure() + } + required init?(coder: NSCoder) { + fatalError("not implemented") + } +} + +extension SectionBackgroundDecorationView { + func configure() { + gradientLayer.colors = [UIColor.systemBackground.withAlphaComponent(0).cgColor, UIColor.systemPink.withAlphaComponent(0.5).cgColor] + layer.addSublayer(gradientLayer) + gradientLayer.frame = layer.bounds + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = layer.bounds + } +} diff --git a/Configuration/SampleCode.xcconfig b/Configuration/SampleCode.xcconfig new file mode 100644 index 0000000..db86c06 --- /dev/null +++ b/Configuration/SampleCode.xcconfig @@ -0,0 +1,13 @@ +// +// See LICENSE folder for this sample’s licensing information. +// +// SampleCode.xcconfig +// + +// The `SAMPLE_CODE_DISAMBIGUATOR` configuration is to make it easier to build +// and run a sample code project. Once you set your project's development team, +// you'll have a unique bundle identifier. This is because the bundle identifier +// is derived based on the 'SAMPLE_CODE_DISAMBIGUATOR' value. Do not use this +// approach in your own projects—it's only useful for sample code projects because +// they are frequently downloaded and don't have a development team set. +SAMPLE_CODE_DISAMBIGUATOR=${DEVELOPMENT_TEAM} diff --git a/LICENSE b/LICENSE deleted file mode 100644 index b32c1ad..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021 Tim Oliver - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LICENSE/ACKNOWLEDGMENTS.txt b/LICENSE/ACKNOWLEDGMENTS.txt new file mode 100644 index 0000000..927d54e --- /dev/null +++ b/LICENSE/ACKNOWLEDGMENTS.txt @@ -0,0 +1,49 @@ +Assets from Unsplash are covered under the Unsplash License: https://unsplash.com/license + +CollectionViewSample/Assets.xcassets/Destinations/cusco.imageset/ +Images created by Paul Lequay +https://unsplash.com/photos/73FOXT1DvjI + +CollectionViewSample/Assets.xcassets/Destinations/st-lucia.imageset/ +Images created by Corinne Kutz +https://unsplash.com/photos/xVCEvpBpe_g + +CollectionViewSample/Assets.xcassets/Destinations/patagonia.imageset/ +Images created by Rodrigo Flores +https://unsplash.com/photos/nk_JKocDkDo + +CollectionViewSample/Assets.xcassets/Destinations/cork.imageset/ +Images created by Yen Tran +https://unsplash.com/photos/y-o266_jp9g + +CollectionViewSample/Assets.xcassets/Destinations/cambodia.imageset/ +Images created by Taylor Simpson +https://unsplash.com/photos/p6-9LcGDhHc + +CollectionViewSample/Assets.xcassets/Destinations/italy.imageset/ +Images created by Sofia +https://unsplash.com/photos/mv--aWdjUn4 + +CollectionViewSample/Assets.xcassets/Destinations/iceland.imageset/ +Images created by Norris Niman +https://unsplash.com/photos/ABtmE3jhaPQ + +CollectionViewSample/Assets.xcassets/Destinations/tokyo.imageset/ +Images created by Freeman Zhou +https://unsplash.com/photos/plX7xeNb3Yo + +CollectionViewSample/Assets.xcassets/Destinations/vietnam.imageset/ +Images created by David Emrich +https://unsplash.com/photos/WirMEd6CMts + +CollectionViewSample/Assets.xcassets/Destinations/new-zealand.imageset/ +Images created by Casey Horner +https://unsplash.com/photos/75_s8iWHKLs + +CollectionViewSample/Assets.xcassets/Destinations/paris.imageset/ +Images created by Adrien +https://unsplash.com/photos/wAScP0OY-yM + +CollectionViewSample/Assets.xcassets/Destinations/bali.imageset/ +Images created by Alexa West +https://unsplash.com/photos/OOTEpsO2eV0 \ No newline at end of file diff --git a/LICENSE/LICENSE.txt b/LICENSE/LICENSE.txt new file mode 100644 index 0000000..c6d058b --- /dev/null +++ b/LICENSE/LICENSE.txt @@ -0,0 +1,9 @@ +Some image assets are sourced from Unsplash www.unplash.com, see ACKNOWLEDGMENTS.txt for credits and more information. + +Copyright © 2021 Apple Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index c170e54..bacb4b6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ -# BuildingHighPerformanceListsAndCollectionViews -A mirror of Apple's sample code for high performance collection views in iOS 15 +# Building High-Performance Lists and Collection Views + +Improve the performance of lists and collections in your app with prefetching and image preparation. + +## Overview + +- Note: This sample code project is associated with WWDC21 session [10252: Make Blazing Fast Lists and Collection Views](https://developer.apple.com/wwdc21/10252/). diff --git a/UITests/UITests.swift b/UITests/UITests.swift new file mode 100644 index 0000000..70f3417 --- /dev/null +++ b/UITests/UITests.swift @@ -0,0 +1,34 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +UI performance tests. +*/ + +import XCTest + +class UITests: XCTestCase { + override func setUpWithError() throws { + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + } + + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + measure(metrics: [XCTOSSignpostMetric.scrollingAndDecelerationMetric]) { + app.collectionViews.firstMatch.swipeUp(velocity: .fast) + } + } + + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +}