├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── Example ├── Example.xcodeproj │ ├── Example.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── Example.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── Example │ ├── ExampleApp.swift │ ├── Info.plist │ └── Shelf.swift ├── LICENSE ├── Package.swift ├── README.md └── Sources └── SwiftUICollection └── CollectionView.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/Example.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 6F80BED92510DF8D00192322 /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F80BED82510DF8D00192322 /* ExampleApp.swift */; }; 11 | 6F80BEE82510E1F700192322 /* Shelf.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F80BEE72510E1F700192322 /* Shelf.swift */; }; 12 | 6F80BEEB2510E22F00192322 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = 6F80BEEA2510E22F00192322 /* SwiftUICollection */; }; 13 | /* End PBXBuildFile section */ 14 | 15 | /* Begin PBXFileReference section */ 16 | 6F80BED52510DF8D00192322 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 17 | 6F80BED82510DF8D00192322 /* ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleApp.swift; sourceTree = ""; }; 18 | 6F80BEE12510DF8F00192322 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 19 | 6F80BEE72510E1F700192322 /* Shelf.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shelf.swift; sourceTree = ""; }; 20 | /* End PBXFileReference section */ 21 | 22 | /* Begin PBXFrameworksBuildPhase section */ 23 | 6F80BED22510DF8D00192322 /* Frameworks */ = { 24 | isa = PBXFrameworksBuildPhase; 25 | buildActionMask = 2147483647; 26 | files = ( 27 | 6F80BEEB2510E22F00192322 /* SwiftUICollection in Frameworks */, 28 | ); 29 | runOnlyForDeploymentPostprocessing = 0; 30 | }; 31 | /* End PBXFrameworksBuildPhase section */ 32 | 33 | /* Begin PBXGroup section */ 34 | 6F80BECC2510DF8D00192322 = { 35 | isa = PBXGroup; 36 | children = ( 37 | 6F80BED72510DF8D00192322 /* Example */, 38 | 6F80BED62510DF8D00192322 /* Products */, 39 | 6F80BEE92510E22F00192322 /* Frameworks */, 40 | ); 41 | sourceTree = ""; 42 | }; 43 | 6F80BED62510DF8D00192322 /* Products */ = { 44 | isa = PBXGroup; 45 | children = ( 46 | 6F80BED52510DF8D00192322 /* Example.app */, 47 | ); 48 | name = Products; 49 | sourceTree = ""; 50 | }; 51 | 6F80BED72510DF8D00192322 /* Example */ = { 52 | isa = PBXGroup; 53 | children = ( 54 | 6F80BEE72510E1F700192322 /* Shelf.swift */, 55 | 6F80BED82510DF8D00192322 /* ExampleApp.swift */, 56 | 6F80BEE12510DF8F00192322 /* Info.plist */, 57 | ); 58 | path = Example; 59 | sourceTree = ""; 60 | }; 61 | 6F80BEE92510E22F00192322 /* Frameworks */ = { 62 | isa = PBXGroup; 63 | children = ( 64 | ); 65 | name = Frameworks; 66 | sourceTree = ""; 67 | }; 68 | /* End PBXGroup section */ 69 | 70 | /* Begin PBXNativeTarget section */ 71 | 6F80BED42510DF8D00192322 /* Example */ = { 72 | isa = PBXNativeTarget; 73 | buildConfigurationList = 6F80BEE42510DF8F00192322 /* Build configuration list for PBXNativeTarget "Example" */; 74 | buildPhases = ( 75 | 6F80BED12510DF8D00192322 /* Sources */, 76 | 6F80BED22510DF8D00192322 /* Frameworks */, 77 | 6F80BED32510DF8D00192322 /* Resources */, 78 | ); 79 | buildRules = ( 80 | ); 81 | dependencies = ( 82 | ); 83 | name = Example; 84 | packageProductDependencies = ( 85 | 6F80BEEA2510E22F00192322 /* SwiftUICollection */, 86 | ); 87 | productName = Example; 88 | productReference = 6F80BED52510DF8D00192322 /* Example.app */; 89 | productType = "com.apple.product-type.application"; 90 | }; 91 | /* End PBXNativeTarget section */ 92 | 93 | /* Begin PBXProject section */ 94 | 6F80BECD2510DF8D00192322 /* Project object */ = { 95 | isa = PBXProject; 96 | attributes = { 97 | LastSwiftUpdateCheck = 1200; 98 | LastUpgradeCheck = 1200; 99 | TargetAttributes = { 100 | 6F80BED42510DF8D00192322 = { 101 | CreatedOnToolsVersion = 12.0; 102 | }; 103 | }; 104 | }; 105 | buildConfigurationList = 6F80BED02510DF8D00192322 /* Build configuration list for PBXProject "Example" */; 106 | compatibilityVersion = "Xcode 9.3"; 107 | developmentRegion = en; 108 | hasScannedForEncodings = 0; 109 | knownRegions = ( 110 | en, 111 | Base, 112 | ); 113 | mainGroup = 6F80BECC2510DF8D00192322; 114 | productRefGroup = 6F80BED62510DF8D00192322 /* Products */; 115 | projectDirPath = ""; 116 | projectRoot = ""; 117 | targets = ( 118 | 6F80BED42510DF8D00192322 /* Example */, 119 | ); 120 | }; 121 | /* End PBXProject section */ 122 | 123 | /* Begin PBXResourcesBuildPhase section */ 124 | 6F80BED32510DF8D00192322 /* Resources */ = { 125 | isa = PBXResourcesBuildPhase; 126 | buildActionMask = 2147483647; 127 | files = ( 128 | ); 129 | runOnlyForDeploymentPostprocessing = 0; 130 | }; 131 | /* End PBXResourcesBuildPhase section */ 132 | 133 | /* Begin PBXSourcesBuildPhase section */ 134 | 6F80BED12510DF8D00192322 /* Sources */ = { 135 | isa = PBXSourcesBuildPhase; 136 | buildActionMask = 2147483647; 137 | files = ( 138 | 6F80BEE82510E1F700192322 /* Shelf.swift in Sources */, 139 | 6F80BED92510DF8D00192322 /* ExampleApp.swift in Sources */, 140 | ); 141 | runOnlyForDeploymentPostprocessing = 0; 142 | }; 143 | /* End PBXSourcesBuildPhase section */ 144 | 145 | /* Begin XCBuildConfiguration section */ 146 | 6F80BEE22510DF8F00192322 /* Debug */ = { 147 | isa = XCBuildConfiguration; 148 | buildSettings = { 149 | ALWAYS_SEARCH_USER_PATHS = NO; 150 | CLANG_ANALYZER_NONNULL = YES; 151 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 152 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 153 | CLANG_CXX_LIBRARY = "libc++"; 154 | CLANG_ENABLE_MODULES = YES; 155 | CLANG_ENABLE_OBJC_ARC = YES; 156 | CLANG_ENABLE_OBJC_WEAK = YES; 157 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 158 | CLANG_WARN_BOOL_CONVERSION = YES; 159 | CLANG_WARN_COMMA = YES; 160 | CLANG_WARN_CONSTANT_CONVERSION = YES; 161 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 162 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 163 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 164 | CLANG_WARN_EMPTY_BODY = YES; 165 | CLANG_WARN_ENUM_CONVERSION = YES; 166 | CLANG_WARN_INFINITE_RECURSION = YES; 167 | CLANG_WARN_INT_CONVERSION = YES; 168 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 169 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 170 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 171 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 172 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 173 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 174 | CLANG_WARN_STRICT_PROTOTYPES = YES; 175 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 176 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 177 | CLANG_WARN_UNREACHABLE_CODE = YES; 178 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 179 | COPY_PHASE_STRIP = NO; 180 | DEBUG_INFORMATION_FORMAT = dwarf; 181 | ENABLE_STRICT_OBJC_MSGSEND = YES; 182 | ENABLE_TESTABILITY = YES; 183 | GCC_C_LANGUAGE_STANDARD = gnu11; 184 | GCC_DYNAMIC_NO_PIC = NO; 185 | GCC_NO_COMMON_BLOCKS = YES; 186 | GCC_OPTIMIZATION_LEVEL = 0; 187 | GCC_PREPROCESSOR_DEFINITIONS = ( 188 | "DEBUG=1", 189 | "$(inherited)", 190 | ); 191 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 192 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 193 | GCC_WARN_UNDECLARED_SELECTOR = YES; 194 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 195 | GCC_WARN_UNUSED_FUNCTION = YES; 196 | GCC_WARN_UNUSED_VARIABLE = YES; 197 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 198 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 199 | MTL_FAST_MATH = YES; 200 | ONLY_ACTIVE_ARCH = YES; 201 | SDKROOT = iphoneos; 202 | SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvsimulator appletvos"; 203 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 204 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 205 | TARGETED_DEVICE_FAMILY = "1,2,3"; 206 | TVOS_DEPLOYMENT_TARGET = 14.0; 207 | }; 208 | name = Debug; 209 | }; 210 | 6F80BEE32510DF8F00192322 /* Release */ = { 211 | isa = XCBuildConfiguration; 212 | buildSettings = { 213 | ALWAYS_SEARCH_USER_PATHS = NO; 214 | CLANG_ANALYZER_NONNULL = YES; 215 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 216 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 217 | CLANG_CXX_LIBRARY = "libc++"; 218 | CLANG_ENABLE_MODULES = YES; 219 | CLANG_ENABLE_OBJC_ARC = YES; 220 | CLANG_ENABLE_OBJC_WEAK = YES; 221 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 222 | CLANG_WARN_BOOL_CONVERSION = YES; 223 | CLANG_WARN_COMMA = YES; 224 | CLANG_WARN_CONSTANT_CONVERSION = YES; 225 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 226 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 227 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 228 | CLANG_WARN_EMPTY_BODY = YES; 229 | CLANG_WARN_ENUM_CONVERSION = YES; 230 | CLANG_WARN_INFINITE_RECURSION = YES; 231 | CLANG_WARN_INT_CONVERSION = YES; 232 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 233 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 234 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 235 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 236 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 237 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 238 | CLANG_WARN_STRICT_PROTOTYPES = YES; 239 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 240 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 241 | CLANG_WARN_UNREACHABLE_CODE = YES; 242 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 243 | COPY_PHASE_STRIP = NO; 244 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 245 | ENABLE_NS_ASSERTIONS = NO; 246 | ENABLE_STRICT_OBJC_MSGSEND = YES; 247 | GCC_C_LANGUAGE_STANDARD = gnu11; 248 | GCC_NO_COMMON_BLOCKS = YES; 249 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 250 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 251 | GCC_WARN_UNDECLARED_SELECTOR = YES; 252 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 253 | GCC_WARN_UNUSED_FUNCTION = YES; 254 | GCC_WARN_UNUSED_VARIABLE = YES; 255 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 256 | MTL_ENABLE_DEBUG_INFO = NO; 257 | MTL_FAST_MATH = YES; 258 | SDKROOT = iphoneos; 259 | SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvsimulator appletvos"; 260 | SWIFT_COMPILATION_MODE = wholemodule; 261 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 262 | TARGETED_DEVICE_FAMILY = "1,2,3"; 263 | TVOS_DEPLOYMENT_TARGET = 14.0; 264 | VALIDATE_PRODUCT = YES; 265 | }; 266 | name = Release; 267 | }; 268 | 6F80BEE52510DF8F00192322 /* Debug */ = { 269 | isa = XCBuildConfiguration; 270 | buildSettings = { 271 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 272 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 273 | CODE_SIGN_STYLE = Automatic; 274 | DEVELOPMENT_TEAM = C859N35D2X; 275 | ENABLE_PREVIEWS = YES; 276 | INFOPLIST_FILE = Example/Info.plist; 277 | LD_RUNPATH_SEARCH_PATHS = ( 278 | "$(inherited)", 279 | "@executable_path/Frameworks", 280 | ); 281 | PRODUCT_BUNDLE_IDENTIFIER = ch.defagos.Example; 282 | PRODUCT_NAME = "$(TARGET_NAME)"; 283 | SWIFT_VERSION = 5.0; 284 | }; 285 | name = Debug; 286 | }; 287 | 6F80BEE62510DF8F00192322 /* Release */ = { 288 | isa = XCBuildConfiguration; 289 | buildSettings = { 290 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 291 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 292 | CODE_SIGN_STYLE = Automatic; 293 | DEVELOPMENT_TEAM = C859N35D2X; 294 | ENABLE_PREVIEWS = YES; 295 | INFOPLIST_FILE = Example/Info.plist; 296 | LD_RUNPATH_SEARCH_PATHS = ( 297 | "$(inherited)", 298 | "@executable_path/Frameworks", 299 | ); 300 | PRODUCT_BUNDLE_IDENTIFIER = ch.defagos.Example; 301 | PRODUCT_NAME = "$(TARGET_NAME)"; 302 | SWIFT_VERSION = 5.0; 303 | }; 304 | name = Release; 305 | }; 306 | /* End XCBuildConfiguration section */ 307 | 308 | /* Begin XCConfigurationList section */ 309 | 6F80BED02510DF8D00192322 /* Build configuration list for PBXProject "Example" */ = { 310 | isa = XCConfigurationList; 311 | buildConfigurations = ( 312 | 6F80BEE22510DF8F00192322 /* Debug */, 313 | 6F80BEE32510DF8F00192322 /* Release */, 314 | ); 315 | defaultConfigurationIsVisible = 0; 316 | defaultConfigurationName = Release; 317 | }; 318 | 6F80BEE42510DF8F00192322 /* Build configuration list for PBXNativeTarget "Example" */ = { 319 | isa = XCConfigurationList; 320 | buildConfigurations = ( 321 | 6F80BEE52510DF8F00192322 /* Debug */, 322 | 6F80BEE62510DF8F00192322 /* Release */, 323 | ); 324 | defaultConfigurationIsVisible = 0; 325 | defaultConfigurationName = Release; 326 | }; 327 | /* End XCConfigurationList section */ 328 | 329 | /* Begin XCSwiftPackageProductDependency section */ 330 | 6F80BEEA2510E22F00192322 /* SwiftUICollection */ = { 331 | isa = XCSwiftPackageProductDependency; 332 | productName = SwiftUICollection; 333 | }; 334 | /* End XCSwiftPackageProductDependency section */ 335 | }; 336 | rootObject = 6F80BECD2510DF8D00192322 /* Project object */; 337 | } 338 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example/ExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Samuel Défago. All rights reserved. 3 | // 4 | // License information is available from the LICENSE file. 5 | // 6 | 7 | import SwiftUI 8 | 9 | @main 10 | struct ExampleApp: App { 11 | var body: some Scene { 12 | WindowGroup { 13 | Shelf() 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Example/Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | 28 | UIApplicationSupportsIndirectInputEvents 29 | 30 | UILaunchScreen 31 | 32 | UIRequiredDeviceCapabilities 33 | 34 | armv7 35 | 36 | UISupportedInterfaceOrientations 37 | 38 | UIInterfaceOrientationPortrait 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UISupportedInterfaceOrientations~ipad 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationPortraitUpsideDown 46 | UIInterfaceOrientationLandscapeLeft 47 | UIInterfaceOrientationLandscapeRight 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /Example/Example/Shelf.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Samuel Défago. All rights reserved. 3 | // 4 | // License information is available from the LICENSE file. 5 | // 6 | 7 | import SwiftUI 8 | import SwiftUICollection 9 | 10 | extension Button { 11 | func cardButtonStyle() -> some View { 12 | #if os(tvOS) 13 | return self.buttonStyle(CardButtonStyle()) 14 | #else 15 | return self 16 | #endif 17 | } 18 | } 19 | 20 | struct Shelf: View { 21 | typealias Row = CollectionRow 22 | 23 | @State var rows = Self.shuffledRows() 24 | 25 | private static func shuffledRows() -> [Row] { 26 | var rows = [Row]() 27 | for i in (0..<40).shuffled() { 28 | rows.append(Row(section: i, items: (0..<20).map { "\(i), \($0)" })) 29 | } 30 | return rows 31 | } 32 | 33 | private func shuffle() { 34 | rows = Self.shuffledRows() 35 | } 36 | 37 | var body: some View { 38 | CollectionView(rows: rows) { sectionIndex, layoutEnvironment in 39 | let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) 40 | let item = NSCollectionLayoutItem(layoutSize: itemSize) 41 | 42 | let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(320), heightDimension: .absolute(180)) 43 | let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) 44 | 45 | let header = NSCollectionLayoutBoundarySupplementaryItem( 46 | layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(44)), 47 | elementKind: UICollectionView.elementKindSectionHeader, 48 | alignment: .topLeading 49 | ) 50 | 51 | let section = NSCollectionLayoutSection(group: group) 52 | section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 0, bottom: 20, trailing: 0) 53 | section.interGroupSpacing = 40 54 | section.orthogonalScrollingBehavior = .continuous 55 | section.boundarySupplementaryItems = [header] 56 | return section 57 | } cell: { indexPath, item in 58 | GeometryReader { geometry in 59 | Button(action: shuffle) { 60 | Text(item) 61 | .foregroundColor(.black) 62 | .frame(width: geometry.size.width, height: geometry.size.height) 63 | .background(Color.blue) 64 | } 65 | .cardButtonStyle() 66 | } 67 | } supplementaryView: { kind, indexPath in 68 | Text("Section \(indexPath.section)") 69 | } 70 | .frame(maxWidth: .infinity, maxHeight: .infinity) 71 | .ignoresSafeArea(.all) 72 | } 73 | } 74 | 75 | struct Shelf_Previews: PreviewProvider { 76 | static var previews: some View { 77 | Shelf() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020, Samuel Défago 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SwiftUICollection", 7 | platforms: [ 8 | .iOS(.v13), 9 | .tvOS(.v13) 10 | ], 11 | products: [ 12 | .library( 13 | name: "SwiftUICollection", 14 | targets: ["SwiftUICollection"] 15 | ) 16 | ], 17 | targets: [ 18 | .target( 19 | name: "SwiftUICollection" 20 | ) 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Building a Collection For SwiftUI - Sample Code 2 | 3 | This is the sample code for my [_Building a Collection For SwiftUI_](http://defagos.github.io/swiftui_collection_intro) article series. 4 | 5 | It contains: 6 | 7 | - A Swift package with a collection for SwiftUI. 8 | - A sample project. 9 | 10 | This collection intends to solve performance issues associated with SwiftUI stack and scroll view nesting, especially on tvOS. It achieves this results by wrapping `UICollectionView` internally. 11 | 12 | ## Purpose 13 | 14 | This code is a companion to the aforementioned article. It should not be used as a library. The Swift package is intended for experimental use in custom projects. 15 | 16 | ## Requirements 17 | 18 | This project must be compiled with Xcode 12. 19 | 20 | ## Compatibility 21 | 22 | The package is compatible with iOS and tvOS 13 and above. The example project runs on iOS and tvOS 14 and above. 23 | 24 | ## License 25 | 26 | See the [LICENSE](../LICENSE) file for more information. -------------------------------------------------------------------------------- /Sources/SwiftUICollection/CollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Samuel Défago. All rights reserved. 3 | // 4 | // License information is available from the LICENSE file. 5 | // 6 | 7 | import SwiftUI 8 | 9 | extension UIHostingController { 10 | convenience public init(rootView: Content, ignoreSafeArea: Bool) { 11 | self.init(rootView: rootView) 12 | 13 | if ignoreSafeArea { 14 | disableSafeArea() 15 | } 16 | } 17 | 18 | func disableSafeArea() { 19 | guard let viewClass = object_getClass(view) else { return } 20 | 21 | let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea") 22 | if let viewSubclass = NSClassFromString(viewSubclassName) { 23 | object_setClass(view, viewSubclass) 24 | } 25 | else { 26 | guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return } 27 | guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return } 28 | 29 | if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) { 30 | let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in 31 | return .zero 32 | } 33 | class_addMethod(viewSubclass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method)) 34 | } 35 | 36 | objc_registerClassPair(viewSubclass) 37 | object_setClass(view, viewSubclass) 38 | } 39 | } 40 | } 41 | 42 | public struct CollectionRow: Hashable { 43 | let section: Section 44 | let items: [Item] 45 | 46 | public init(section: Section, items: [Item]) { 47 | self.section = section 48 | self.items = items 49 | } 50 | } 51 | 52 | public struct CollectionView: UIViewRepresentable { 53 | private class HostCell: UICollectionViewCell { 54 | private var hostController: UIHostingController? 55 | 56 | override func prepareForReuse() { 57 | if let hostView = hostController?.view { 58 | hostView.removeFromSuperview() 59 | } 60 | hostController = nil 61 | } 62 | 63 | var hostedCell: Cell? { 64 | willSet { 65 | guard let view = newValue else { return } 66 | hostController = UIHostingController(rootView: view, ignoreSafeArea: true) 67 | if let hostView = hostController?.view { 68 | hostView.frame = contentView.bounds 69 | hostView.autoresizingMask = [.flexibleWidth, .flexibleHeight] 70 | contentView.addSubview(hostView) 71 | } 72 | } 73 | } 74 | } 75 | 76 | private class HostSupplementaryView: UICollectionReusableView { 77 | private var hostController: UIHostingController? 78 | 79 | override func prepareForReuse() { 80 | if let hostView = hostController?.view { 81 | hostView.removeFromSuperview() 82 | } 83 | hostController = nil 84 | } 85 | 86 | var hostedSupplementaryView: SupplementaryView? { 87 | willSet { 88 | guard let view = newValue else { return } 89 | hostController = UIHostingController(rootView: view, ignoreSafeArea: true) 90 | if let hostView = hostController?.view { 91 | hostView.frame = self.bounds 92 | hostView.autoresizingMask = [.flexibleWidth, .flexibleHeight] 93 | addSubview(hostView) 94 | } 95 | } 96 | } 97 | } 98 | 99 | public class Coordinator: NSObject, UICollectionViewDelegate { 100 | fileprivate typealias DataSource = UICollectionViewDiffableDataSource 101 | 102 | fileprivate var dataSource: DataSource? = nil 103 | fileprivate var sectionLayoutProvider: ((Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection)? 104 | fileprivate var rowsHash: Int? = nil 105 | fileprivate var registeredSupplementaryViewKinds: [String] = [] 106 | fileprivate var isFocusable: Bool = false 107 | 108 | public func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool { 109 | return isFocusable 110 | } 111 | } 112 | 113 | let rows: [CollectionRow] 114 | let sectionLayoutProvider: (Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection 115 | let cell: (IndexPath, Item) -> Cell 116 | let supplementaryView: (String, IndexPath) -> SupplementaryView 117 | 118 | public init(rows: [CollectionRow], 119 | sectionLayoutProvider: @escaping (Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection, 120 | @ViewBuilder cell: @escaping (IndexPath, Item) -> Cell, 121 | @ViewBuilder supplementaryView: @escaping (String, IndexPath) -> SupplementaryView) { 122 | self.rows = rows 123 | self.sectionLayoutProvider = sectionLayoutProvider 124 | self.cell = cell 125 | self.supplementaryView = supplementaryView 126 | } 127 | 128 | private func layout(context: Context) -> UICollectionViewLayout { 129 | return UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in 130 | return context.coordinator.sectionLayoutProvider!(sectionIndex, layoutEnvironment) 131 | } 132 | } 133 | 134 | private func snapshot() -> NSDiffableDataSourceSnapshot { 135 | var snapshot = NSDiffableDataSourceSnapshot() 136 | for row in rows { 137 | snapshot.appendSections([row.section]) 138 | snapshot.appendItems(row.items, toSection: row.section) 139 | } 140 | return snapshot 141 | } 142 | 143 | private func reloadData(in collectionView: UICollectionView, context: Context, animated: Bool = false) { 144 | let coordinator = context.coordinator 145 | coordinator.sectionLayoutProvider = self.sectionLayoutProvider 146 | 147 | guard let dataSource = coordinator.dataSource else { return } 148 | 149 | let rowsHash = rows.hashValue 150 | if coordinator.rowsHash != rowsHash { 151 | dataSource.apply(snapshot(), animatingDifferences: animated) { 152 | coordinator.isFocusable = true 153 | collectionView.setNeedsFocusUpdate() 154 | collectionView.updateFocusIfNeeded() 155 | coordinator.isFocusable = false 156 | } 157 | coordinator.rowsHash = rowsHash 158 | } 159 | } 160 | 161 | public func makeCoordinator() -> Coordinator { 162 | return Coordinator() 163 | } 164 | 165 | public func makeUIView(context: Context) -> UICollectionView { 166 | let cellIdentifier = "hostCell" 167 | let supplementaryViewIdentifier = "hostSupplementaryView" 168 | 169 | let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout(context: context)) 170 | collectionView.delegate = context.coordinator 171 | collectionView.register(HostCell.self, forCellWithReuseIdentifier: cellIdentifier) 172 | 173 | let dataSource = Coordinator.DataSource(collectionView: collectionView) { collectionView, indexPath, item in 174 | let hostCell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) as? HostCell 175 | hostCell?.hostedCell = cell(indexPath, item) 176 | return hostCell 177 | } 178 | context.coordinator.dataSource = dataSource 179 | 180 | dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in 181 | let coordinator = context.coordinator 182 | if !coordinator.registeredSupplementaryViewKinds.contains(kind) { 183 | collectionView.register(HostSupplementaryView.self, forSupplementaryViewOfKind: kind, withReuseIdentifier: supplementaryViewIdentifier) 184 | coordinator.registeredSupplementaryViewKinds.append(kind) 185 | } 186 | 187 | guard let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: supplementaryViewIdentifier, for: indexPath) as? HostSupplementaryView else { return nil } 188 | view.hostedSupplementaryView = supplementaryView(kind, indexPath) 189 | return view 190 | } 191 | 192 | reloadData(in: collectionView, context: context) 193 | return collectionView 194 | } 195 | 196 | public func updateUIView(_ uiView: UICollectionView, context: Context) { 197 | reloadData(in: uiView, context: context, animated: true) 198 | } 199 | } 200 | --------------------------------------------------------------------------------