├── .gitignore ├── Demo.gif ├── ExpandingCollectionViewCell.xcodeproj └── project.pbxproj ├── ExpandingCollectionViewCell ├── App │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Info.plist │ └── SceneDelegate.swift ├── Base.lproj │ └── Main.storyboard ├── PeopleViewController.swift ├── Person.swift └── PersonCell.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/xcode,macos,swift 2 | # Edit at https://www.gitignore.io/?templates=xcode,macos,swift 3 | 4 | ### macOS ### 5 | # General 6 | .DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | 10 | # Icon must end with two \r 11 | Icon 12 | 13 | # Thumbnails 14 | ._* 15 | 16 | # Files that might appear in the root of a volume 17 | .DocumentRevisions-V100 18 | .fseventsd 19 | .Spotlight-V100 20 | .TemporaryItems 21 | .Trashes 22 | .VolumeIcon.icns 23 | .com.apple.timemachine.donotpresent 24 | 25 | # Directories potentially created on remote AFP share 26 | .AppleDB 27 | .AppleDesktop 28 | Network Trash Folder 29 | Temporary Items 30 | .apdisk 31 | 32 | ### Swift ### 33 | # Xcode 34 | # 35 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 36 | 37 | ## Build generated 38 | build/ 39 | DerivedData/ 40 | 41 | ## Various settings 42 | *.pbxuser 43 | !default.pbxuser 44 | *.mode1v3 45 | !default.mode1v3 46 | *.mode2v3 47 | !default.mode2v3 48 | *.perspectivev3 49 | !default.perspectivev3 50 | xcuserdata/ 51 | 52 | ## Other 53 | *.moved-aside 54 | *.xccheckout 55 | *.xcscmblueprint 56 | 57 | ## Obj-C/Swift specific 58 | *.hmap 59 | *.ipa 60 | *.dSYM.zip 61 | *.dSYM 62 | 63 | ## Playgrounds 64 | timeline.xctimeline 65 | playground.xcworkspace 66 | 67 | # Swift Package Manager 68 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 69 | # Packages/ 70 | # Package.pins 71 | # Package.resolved 72 | .build/ 73 | # Add this line if you want to avoid checking in Xcode SPM integration. 74 | # .swiftpm/xcode 75 | 76 | # CocoaPods 77 | # We recommend against adding the Pods directory to your .gitignore. However 78 | # you should judge for yourself, the pros and cons are mentioned at: 79 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 80 | # Pods/ 81 | # Add this line if you want to avoid checking in source code from the Xcode workspace 82 | # *.xcworkspace 83 | 84 | # Carthage 85 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 86 | # Carthage/Checkouts 87 | 88 | Carthage/Build 89 | 90 | # Accio dependency management 91 | Dependencies/ 92 | .accio/ 93 | 94 | # fastlane 95 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 96 | # screenshots whenever they are needed. 97 | # For more information about the recommended setup visit: 98 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 99 | 100 | fastlane/report.xml 101 | fastlane/Preview.html 102 | fastlane/screenshots/**/*.png 103 | fastlane/test_output 104 | 105 | # Code Injection 106 | # After new code Injection tools there's a generated folder /iOSInjectionProject 107 | # https://github.com/johnno1962/injectionforxcode 108 | 109 | iOSInjectionProject/ 110 | 111 | ### Xcode ### 112 | # Xcode 113 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 114 | 115 | ## User settings 116 | 117 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 118 | 119 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 120 | 121 | ## Xcode Patch 122 | *.xcodeproj/* 123 | !*.xcodeproj/project.pbxproj 124 | !*.xcodeproj/xcshareddata/ 125 | !*.xcworkspace/contents.xcworkspacedata 126 | /*.gcno 127 | 128 | ### Xcode Patch ### 129 | **/xcshareddata/WorkspaceSettings.xcsettings 130 | 131 | # End of https://www.gitignore.io/api/xcode,macos,swift 132 | 133 | *.orig -------------------------------------------------------------------------------- /Demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swift-student/ExpandingCollectionViewCell/664ac88b1d7d2cca59ca367c2dd410cd30ac5abe/Demo.gif -------------------------------------------------------------------------------- /ExpandingCollectionViewCell.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 045AFC8325225D47000E803D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045AFC8225225D47000E803D /* AppDelegate.swift */; }; 11 | 045AFC8525225D47000E803D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045AFC8425225D47000E803D /* SceneDelegate.swift */; }; 12 | 045AFC8725225D47000E803D /* PeopleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045AFC8625225D47000E803D /* PeopleViewController.swift */; }; 13 | 045AFC8A25225D47000E803D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 045AFC8825225D47000E803D /* Main.storyboard */; }; 14 | 045AFC8C25225D48000E803D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 045AFC8B25225D48000E803D /* Assets.xcassets */; }; 15 | 045AFC8F25225D48000E803D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 045AFC8D25225D48000E803D /* LaunchScreen.storyboard */; }; 16 | 04C1A5942523AFE100793ABD /* PersonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C1A5932523AFE100793ABD /* PersonCell.swift */; }; 17 | 04C1A5962523B04700793ABD /* Person.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C1A5952523B04700793ABD /* Person.swift */; }; 18 | /* End PBXBuildFile section */ 19 | 20 | /* Begin PBXFileReference section */ 21 | 045AFC7F25225D47000E803D /* ExpandingCollectionViewCell.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExpandingCollectionViewCell.app; sourceTree = BUILT_PRODUCTS_DIR; }; 22 | 045AFC8225225D47000E803D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 23 | 045AFC8425225D47000E803D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 24 | 045AFC8625225D47000E803D /* PeopleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeopleViewController.swift; sourceTree = ""; }; 25 | 045AFC8925225D47000E803D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 26 | 045AFC8B25225D48000E803D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 27 | 045AFC8E25225D48000E803D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 28 | 045AFC9025225D48000E803D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 29 | 04C1A5932523AFE100793ABD /* PersonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonCell.swift; sourceTree = ""; }; 30 | 04C1A5952523B04700793ABD /* Person.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Person.swift; sourceTree = ""; }; 31 | /* End PBXFileReference section */ 32 | 33 | /* Begin PBXFrameworksBuildPhase section */ 34 | 045AFC7C25225D47000E803D /* Frameworks */ = { 35 | isa = PBXFrameworksBuildPhase; 36 | buildActionMask = 2147483647; 37 | files = ( 38 | ); 39 | runOnlyForDeploymentPostprocessing = 0; 40 | }; 41 | /* End PBXFrameworksBuildPhase section */ 42 | 43 | /* Begin PBXGroup section */ 44 | 045AFC7625225D46000E803D = { 45 | isa = PBXGroup; 46 | children = ( 47 | 045AFC8125225D47000E803D /* ExpandingCollectionViewCell */, 48 | 045AFC8025225D47000E803D /* Products */, 49 | ); 50 | sourceTree = ""; 51 | }; 52 | 045AFC8025225D47000E803D /* Products */ = { 53 | isa = PBXGroup; 54 | children = ( 55 | 045AFC7F25225D47000E803D /* ExpandingCollectionViewCell.app */, 56 | ); 57 | name = Products; 58 | sourceTree = ""; 59 | }; 60 | 045AFC8125225D47000E803D /* ExpandingCollectionViewCell */ = { 61 | isa = PBXGroup; 62 | children = ( 63 | 04C1A5972523BE1E00793ABD /* App */, 64 | 04C1A5952523B04700793ABD /* Person.swift */, 65 | 045AFC8625225D47000E803D /* PeopleViewController.swift */, 66 | 04C1A5932523AFE100793ABD /* PersonCell.swift */, 67 | 045AFC8825225D47000E803D /* Main.storyboard */, 68 | ); 69 | path = ExpandingCollectionViewCell; 70 | sourceTree = ""; 71 | }; 72 | 04C1A5972523BE1E00793ABD /* App */ = { 73 | isa = PBXGroup; 74 | children = ( 75 | 045AFC8225225D47000E803D /* AppDelegate.swift */, 76 | 045AFC8425225D47000E803D /* SceneDelegate.swift */, 77 | 045AFC8B25225D48000E803D /* Assets.xcassets */, 78 | 045AFC8D25225D48000E803D /* LaunchScreen.storyboard */, 79 | 045AFC9025225D48000E803D /* Info.plist */, 80 | ); 81 | path = App; 82 | sourceTree = ""; 83 | }; 84 | /* End PBXGroup section */ 85 | 86 | /* Begin PBXNativeTarget section */ 87 | 045AFC7E25225D47000E803D /* ExpandingCollectionViewCell */ = { 88 | isa = PBXNativeTarget; 89 | buildConfigurationList = 045AFC9E25225D49000E803D /* Build configuration list for PBXNativeTarget "ExpandingCollectionViewCell" */; 90 | buildPhases = ( 91 | 045AFC7B25225D47000E803D /* Sources */, 92 | 045AFC7C25225D47000E803D /* Frameworks */, 93 | 045AFC7D25225D47000E803D /* Resources */, 94 | ); 95 | buildRules = ( 96 | ); 97 | dependencies = ( 98 | ); 99 | name = ExpandingCollectionViewCell; 100 | packageProductDependencies = ( 101 | ); 102 | productName = ExpandingCollectionViewCell; 103 | productReference = 045AFC7F25225D47000E803D /* ExpandingCollectionViewCell.app */; 104 | productType = "com.apple.product-type.application"; 105 | }; 106 | /* End PBXNativeTarget section */ 107 | 108 | /* Begin PBXProject section */ 109 | 045AFC7725225D46000E803D /* Project object */ = { 110 | isa = PBXProject; 111 | attributes = { 112 | LastSwiftUpdateCheck = 1160; 113 | LastUpgradeCheck = 1160; 114 | ORGANIZATIONNAME = "Swift Student"; 115 | TargetAttributes = { 116 | 045AFC7E25225D47000E803D = { 117 | CreatedOnToolsVersion = 11.6; 118 | }; 119 | }; 120 | }; 121 | buildConfigurationList = 045AFC7A25225D46000E803D /* Build configuration list for PBXProject "ExpandingCollectionViewCell" */; 122 | compatibilityVersion = "Xcode 9.3"; 123 | developmentRegion = en; 124 | hasScannedForEncodings = 0; 125 | knownRegions = ( 126 | en, 127 | Base, 128 | ); 129 | mainGroup = 045AFC7625225D46000E803D; 130 | packageReferences = ( 131 | ); 132 | productRefGroup = 045AFC8025225D47000E803D /* Products */; 133 | projectDirPath = ""; 134 | projectRoot = ""; 135 | targets = ( 136 | 045AFC7E25225D47000E803D /* ExpandingCollectionViewCell */, 137 | ); 138 | }; 139 | /* End PBXProject section */ 140 | 141 | /* Begin PBXResourcesBuildPhase section */ 142 | 045AFC7D25225D47000E803D /* Resources */ = { 143 | isa = PBXResourcesBuildPhase; 144 | buildActionMask = 2147483647; 145 | files = ( 146 | 045AFC8F25225D48000E803D /* LaunchScreen.storyboard in Resources */, 147 | 045AFC8C25225D48000E803D /* Assets.xcassets in Resources */, 148 | 045AFC8A25225D47000E803D /* Main.storyboard in Resources */, 149 | ); 150 | runOnlyForDeploymentPostprocessing = 0; 151 | }; 152 | /* End PBXResourcesBuildPhase section */ 153 | 154 | /* Begin PBXSourcesBuildPhase section */ 155 | 045AFC7B25225D47000E803D /* Sources */ = { 156 | isa = PBXSourcesBuildPhase; 157 | buildActionMask = 2147483647; 158 | files = ( 159 | 04C1A5962523B04700793ABD /* Person.swift in Sources */, 160 | 045AFC8725225D47000E803D /* PeopleViewController.swift in Sources */, 161 | 045AFC8325225D47000E803D /* AppDelegate.swift in Sources */, 162 | 04C1A5942523AFE100793ABD /* PersonCell.swift in Sources */, 163 | 045AFC8525225D47000E803D /* SceneDelegate.swift in Sources */, 164 | ); 165 | runOnlyForDeploymentPostprocessing = 0; 166 | }; 167 | /* End PBXSourcesBuildPhase section */ 168 | 169 | /* Begin PBXVariantGroup section */ 170 | 045AFC8825225D47000E803D /* Main.storyboard */ = { 171 | isa = PBXVariantGroup; 172 | children = ( 173 | 045AFC8925225D47000E803D /* Base */, 174 | ); 175 | name = Main.storyboard; 176 | sourceTree = ""; 177 | }; 178 | 045AFC8D25225D48000E803D /* LaunchScreen.storyboard */ = { 179 | isa = PBXVariantGroup; 180 | children = ( 181 | 045AFC8E25225D48000E803D /* Base */, 182 | ); 183 | name = LaunchScreen.storyboard; 184 | sourceTree = ""; 185 | }; 186 | /* End PBXVariantGroup section */ 187 | 188 | /* Begin XCBuildConfiguration section */ 189 | 045AFC9C25225D49000E803D /* Debug */ = { 190 | isa = XCBuildConfiguration; 191 | buildSettings = { 192 | ALWAYS_SEARCH_USER_PATHS = NO; 193 | CLANG_ANALYZER_NONNULL = YES; 194 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 195 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 196 | CLANG_CXX_LIBRARY = "libc++"; 197 | CLANG_ENABLE_MODULES = YES; 198 | CLANG_ENABLE_OBJC_ARC = YES; 199 | CLANG_ENABLE_OBJC_WEAK = YES; 200 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 201 | CLANG_WARN_BOOL_CONVERSION = YES; 202 | CLANG_WARN_COMMA = YES; 203 | CLANG_WARN_CONSTANT_CONVERSION = YES; 204 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 205 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 206 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 207 | CLANG_WARN_EMPTY_BODY = YES; 208 | CLANG_WARN_ENUM_CONVERSION = YES; 209 | CLANG_WARN_INFINITE_RECURSION = YES; 210 | CLANG_WARN_INT_CONVERSION = YES; 211 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 212 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 213 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 214 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 215 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 216 | CLANG_WARN_STRICT_PROTOTYPES = YES; 217 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 218 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 219 | CLANG_WARN_UNREACHABLE_CODE = YES; 220 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 221 | COPY_PHASE_STRIP = NO; 222 | DEBUG_INFORMATION_FORMAT = dwarf; 223 | ENABLE_STRICT_OBJC_MSGSEND = YES; 224 | ENABLE_TESTABILITY = YES; 225 | GCC_C_LANGUAGE_STANDARD = gnu11; 226 | GCC_DYNAMIC_NO_PIC = NO; 227 | GCC_NO_COMMON_BLOCKS = YES; 228 | GCC_OPTIMIZATION_LEVEL = 0; 229 | GCC_PREPROCESSOR_DEFINITIONS = ( 230 | "DEBUG=1", 231 | "$(inherited)", 232 | ); 233 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 234 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 235 | GCC_WARN_UNDECLARED_SELECTOR = YES; 236 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 237 | GCC_WARN_UNUSED_FUNCTION = YES; 238 | GCC_WARN_UNUSED_VARIABLE = YES; 239 | IPHONEOS_DEPLOYMENT_TARGET = 13.6; 240 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 241 | MTL_FAST_MATH = YES; 242 | ONLY_ACTIVE_ARCH = YES; 243 | SDKROOT = iphoneos; 244 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 245 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 246 | }; 247 | name = Debug; 248 | }; 249 | 045AFC9D25225D49000E803D /* Release */ = { 250 | isa = XCBuildConfiguration; 251 | buildSettings = { 252 | ALWAYS_SEARCH_USER_PATHS = NO; 253 | CLANG_ANALYZER_NONNULL = YES; 254 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 255 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 256 | CLANG_CXX_LIBRARY = "libc++"; 257 | CLANG_ENABLE_MODULES = YES; 258 | CLANG_ENABLE_OBJC_ARC = YES; 259 | CLANG_ENABLE_OBJC_WEAK = YES; 260 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 261 | CLANG_WARN_BOOL_CONVERSION = YES; 262 | CLANG_WARN_COMMA = YES; 263 | CLANG_WARN_CONSTANT_CONVERSION = YES; 264 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 265 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 266 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 267 | CLANG_WARN_EMPTY_BODY = YES; 268 | CLANG_WARN_ENUM_CONVERSION = YES; 269 | CLANG_WARN_INFINITE_RECURSION = YES; 270 | CLANG_WARN_INT_CONVERSION = YES; 271 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 272 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 273 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 274 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 275 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 276 | CLANG_WARN_STRICT_PROTOTYPES = YES; 277 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 278 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 279 | CLANG_WARN_UNREACHABLE_CODE = YES; 280 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 281 | COPY_PHASE_STRIP = NO; 282 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 283 | ENABLE_NS_ASSERTIONS = NO; 284 | ENABLE_STRICT_OBJC_MSGSEND = YES; 285 | GCC_C_LANGUAGE_STANDARD = gnu11; 286 | GCC_NO_COMMON_BLOCKS = YES; 287 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 288 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 289 | GCC_WARN_UNDECLARED_SELECTOR = YES; 290 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 291 | GCC_WARN_UNUSED_FUNCTION = YES; 292 | GCC_WARN_UNUSED_VARIABLE = YES; 293 | IPHONEOS_DEPLOYMENT_TARGET = 13.6; 294 | MTL_ENABLE_DEBUG_INFO = NO; 295 | MTL_FAST_MATH = YES; 296 | SDKROOT = iphoneos; 297 | SWIFT_COMPILATION_MODE = wholemodule; 298 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 299 | VALIDATE_PRODUCT = YES; 300 | }; 301 | name = Release; 302 | }; 303 | 045AFC9F25225D49000E803D /* Debug */ = { 304 | isa = XCBuildConfiguration; 305 | buildSettings = { 306 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 307 | CODE_SIGN_STYLE = Automatic; 308 | DEVELOPMENT_TEAM = 94ZMA2MYR4; 309 | INFOPLIST_FILE = ExpandingCollectionViewCell/App/Info.plist; 310 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 311 | LD_RUNPATH_SEARCH_PATHS = ( 312 | "$(inherited)", 313 | "@executable_path/Frameworks", 314 | ); 315 | PRODUCT_BUNDLE_IDENTIFIER = com.swiftstudent.ExpandingCollectionViewCell; 316 | PRODUCT_NAME = "$(TARGET_NAME)"; 317 | SWIFT_VERSION = 5.0; 318 | TARGETED_DEVICE_FAMILY = "1,2"; 319 | }; 320 | name = Debug; 321 | }; 322 | 045AFCA025225D49000E803D /* Release */ = { 323 | isa = XCBuildConfiguration; 324 | buildSettings = { 325 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 326 | CODE_SIGN_STYLE = Automatic; 327 | DEVELOPMENT_TEAM = 94ZMA2MYR4; 328 | INFOPLIST_FILE = ExpandingCollectionViewCell/App/Info.plist; 329 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 330 | LD_RUNPATH_SEARCH_PATHS = ( 331 | "$(inherited)", 332 | "@executable_path/Frameworks", 333 | ); 334 | PRODUCT_BUNDLE_IDENTIFIER = com.swiftstudent.ExpandingCollectionViewCell; 335 | PRODUCT_NAME = "$(TARGET_NAME)"; 336 | SWIFT_VERSION = 5.0; 337 | TARGETED_DEVICE_FAMILY = "1,2"; 338 | }; 339 | name = Release; 340 | }; 341 | /* End XCBuildConfiguration section */ 342 | 343 | /* Begin XCConfigurationList section */ 344 | 045AFC7A25225D46000E803D /* Build configuration list for PBXProject "ExpandingCollectionViewCell" */ = { 345 | isa = XCConfigurationList; 346 | buildConfigurations = ( 347 | 045AFC9C25225D49000E803D /* Debug */, 348 | 045AFC9D25225D49000E803D /* Release */, 349 | ); 350 | defaultConfigurationIsVisible = 0; 351 | defaultConfigurationName = Release; 352 | }; 353 | 045AFC9E25225D49000E803D /* Build configuration list for PBXNativeTarget "ExpandingCollectionViewCell" */ = { 354 | isa = XCConfigurationList; 355 | buildConfigurations = ( 356 | 045AFC9F25225D49000E803D /* Debug */, 357 | 045AFCA025225D49000E803D /* Release */, 358 | ); 359 | defaultConfigurationIsVisible = 0; 360 | defaultConfigurationName = Release; 361 | }; 362 | /* End XCConfigurationList section */ 363 | }; 364 | rootObject = 045AFC7725225D46000E803D /* Project object */; 365 | } 366 | -------------------------------------------------------------------------------- /ExpandingCollectionViewCell/App/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // ExpandingCollectionViewCell 4 | // 5 | // Created by Shawn Gee on 9/28/20. 6 | // Copyright © 2020 Swift Student. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | 15 | 16 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 17 | // Override point for customization after application launch. 18 | return true 19 | } 20 | 21 | // MARK: UISceneSession Lifecycle 22 | 23 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 24 | // Called when a new scene session is being created. 25 | // Use this method to select a configuration to create the new scene with. 26 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 27 | } 28 | 29 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 30 | // Called when the user discards a scene session. 31 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 32 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 33 | } 34 | 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /ExpandingCollectionViewCell/App/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /ExpandingCollectionViewCell/App/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ExpandingCollectionViewCell/App/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /ExpandingCollectionViewCell/App/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 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | UISceneStoryboardFile 37 | Main 38 | 39 | 40 | 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIMainStoryboardFile 45 | Main 46 | UIRequiredDeviceCapabilities 47 | 48 | armv7 49 | 50 | UISupportedInterfaceOrientations 51 | 52 | UIInterfaceOrientationPortrait 53 | UIInterfaceOrientationLandscapeLeft 54 | UIInterfaceOrientationLandscapeRight 55 | 56 | UISupportedInterfaceOrientations~ipad 57 | 58 | UIInterfaceOrientationPortrait 59 | UIInterfaceOrientationPortraitUpsideDown 60 | UIInterfaceOrientationLandscapeLeft 61 | UIInterfaceOrientationLandscapeRight 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /ExpandingCollectionViewCell/App/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // ExpandingCollectionViewCell 4 | // 5 | // Created by Shawn Gee on 9/28/20. 6 | // Copyright © 2020 Swift Student. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 12 | 13 | var window: UIWindow? 14 | 15 | 16 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 17 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 18 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 19 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 20 | guard let _ = (scene as? UIWindowScene) else { return } 21 | } 22 | 23 | func sceneDidDisconnect(_ scene: UIScene) { 24 | // Called as the scene is being released by the system. 25 | // This occurs shortly after the scene enters the background, or when its session is discarded. 26 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 27 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 28 | } 29 | 30 | func sceneDidBecomeActive(_ scene: UIScene) { 31 | // Called when the scene has moved from an inactive state to an active state. 32 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 33 | } 34 | 35 | func sceneWillResignActive(_ scene: UIScene) { 36 | // Called when the scene will move from an active state to an inactive state. 37 | // This may occur due to temporary interruptions (ex. an incoming phone call). 38 | } 39 | 40 | func sceneWillEnterForeground(_ scene: UIScene) { 41 | // Called as the scene transitions from the background to the foreground. 42 | // Use this method to undo the changes made on entering the background. 43 | } 44 | 45 | func sceneDidEnterBackground(_ scene: UIScene) { 46 | // Called as the scene transitions from the foreground to the background. 47 | // Use this method to save data, release shared resources, and store enough scene-specific state information 48 | // to restore the scene back to its current state. 49 | } 50 | 51 | 52 | } 53 | 54 | -------------------------------------------------------------------------------- /ExpandingCollectionViewCell/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ExpandingCollectionViewCell/PeopleViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PeopleViewController.swift 3 | // ExpandingCollectionViewCell 4 | // 5 | // Created by Shawn Gee on 9/28/20. 6 | // Copyright © 2020 Swift Student. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UIKit 11 | 12 | class PeopleViewController: UIViewController { 13 | enum Section { 14 | case main 15 | } 16 | 17 | // MARK: - Private Properties 18 | 19 | private let people: [Person] = [ 20 | Person(name: "Shawn", age: 31, favoriteColor: "Blue", favoriteMovie: "Dinner For Schmucks"), 21 | Person(name: "Bob", age: 54, favoriteColor: "Red", favoriteMovie: "Saving Private Ryan"), 22 | Person(name: "Susan", age: 23, favoriteColor: "Teal", favoriteMovie: "The Lion King"), 23 | ] 24 | 25 | private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) 26 | private var dataSource: UICollectionViewDiffableDataSource? 27 | 28 | private let padding: CGFloat = 12 29 | 30 | // MARK: - View Lifecycle 31 | 32 | override func viewDidLoad() { 33 | super.viewDidLoad() 34 | setUpCollectionView() 35 | setUpDataSource() 36 | collectionView.delegate = self 37 | } 38 | 39 | // MARK: - Private Methods 40 | 41 | private func createLayout() -> UICollectionViewLayout { 42 | // The item and group will share this size to allow for automatic sizing of the cell's height 43 | let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), 44 | heightDimension: .estimated(50)) 45 | 46 | let item = NSCollectionLayoutItem(layoutSize: itemSize) 47 | 48 | let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, 49 | subitems: [item]) 50 | 51 | let section = NSCollectionLayoutSection(group: group) 52 | section.interGroupSpacing = padding 53 | section.contentInsets = .init(top: padding, leading: padding, bottom: padding, trailing: padding) 54 | 55 | return UICollectionViewCompositionalLayout(section: section) 56 | } 57 | 58 | private func setUpCollectionView() { 59 | collectionView.register(PersonCell.self, forCellWithReuseIdentifier: String(describing: PersonCell.self)) 60 | collectionView.backgroundColor = .white 61 | 62 | view.addSubview(collectionView) 63 | collectionView.translatesAutoresizingMaskIntoConstraints = false 64 | 65 | NSLayoutConstraint.activate([ 66 | collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 67 | collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), 68 | collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), 69 | collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), 70 | ]) 71 | } 72 | 73 | private func setUpDataSource() { 74 | dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { 75 | (collectionView, indexPath, person) -> UICollectionViewCell? in 76 | guard let cell = collectionView.dequeueReusableCell( 77 | withReuseIdentifier: String(describing: PersonCell.self), 78 | for: indexPath) as? PersonCell else { 79 | fatalError("Could not cast cell as \(PersonCell.self)") 80 | } 81 | cell.person = person 82 | return cell 83 | } 84 | collectionView.dataSource = dataSource 85 | 86 | var snapshot = NSDiffableDataSourceSnapshot() 87 | snapshot.appendSections([.main]) 88 | snapshot.appendItems(people) 89 | dataSource?.apply(snapshot) 90 | } 91 | } 92 | 93 | // MARK: - Collection View Delegate 94 | 95 | extension PeopleViewController: UICollectionViewDelegate { 96 | func collectionView(_ collectionView: UICollectionView, 97 | shouldSelectItemAt indexPath: IndexPath) -> Bool { 98 | guard let dataSource = dataSource else { return false } 99 | 100 | // Allows for closing an already open cell 101 | if collectionView.indexPathsForSelectedItems?.contains(indexPath) ?? false { 102 | collectionView.deselectItem(at: indexPath, animated: true) 103 | } else { 104 | collectionView.selectItem(at: indexPath, animated: true, scrollPosition: []) 105 | } 106 | 107 | dataSource.refresh() 108 | 109 | return false // The selecting or deselecting is already performed above 110 | } 111 | } 112 | 113 | extension UICollectionViewDiffableDataSource { 114 | /// Reapplies the current snapshot to the data source, animating the differences. 115 | /// - Parameters: 116 | /// - completion: A closure to be called on completion of reapplying the snapshot. 117 | func refresh(completion: (() -> Void)? = nil) { 118 | self.apply(self.snapshot(), animatingDifferences: true, completion: completion) 119 | } 120 | } 121 | 122 | 123 | // MARK: - SwiftUI Previews 124 | 125 | struct PeopleVCWrapper: UIViewRepresentable { 126 | func makeUIView(context: UIViewRepresentableContext) -> UIView { 127 | PeopleViewController().view 128 | } 129 | 130 | func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) {} 131 | } 132 | 133 | struct PeopleVCWrapper_Previews: PreviewProvider { 134 | static var previews: some View { 135 | PeopleVCWrapper().edgesIgnoringSafeArea(.all) // remove this to respect safe area 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /ExpandingCollectionViewCell/Person.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Person.swift 3 | // ExpandingCollectionViewCell 4 | // 5 | // Created by Shawn Gee on 9/29/20. 6 | // Copyright © 2020 Swift Student. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Person { 12 | let name: String 13 | let age: Int 14 | let favoriteColor: String 15 | let favoriteMovie: String 16 | let id = UUID() 17 | } 18 | 19 | // Person must be hashable in order to be used as the item identifier in a diffable data source 20 | extension Person: Hashable { 21 | static func == (lhs: Person, rhs: Person) -> Bool { 22 | lhs.id == rhs.id 23 | } 24 | 25 | func hash(into hasher: inout Hasher) { 26 | hasher.combine(id) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ExpandingCollectionViewCell/PersonCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersonCell.swift 3 | // ExpandingCollectionViewCell 4 | // 5 | // Created by Shawn Gee on 9/29/20. 6 | // Copyright © 2020 Swift Student. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class PersonCell: UICollectionViewCell { 12 | 13 | // MARK: - Public Properties 14 | 15 | var person: Person? { didSet { updateContent() } } 16 | override var isSelected: Bool { didSet { updateAppearance() } } 17 | 18 | // MARK: - Private Properties 19 | 20 | // Views 21 | private let nameLabel: UILabel = { 22 | let nameLabel = UILabel() 23 | nameLabel.font = .preferredFont(forTextStyle: .headline) 24 | return nameLabel 25 | }() 26 | private let ageLabel = UILabel() 27 | private let favoriteColorLabel = UILabel() 28 | private let favoriteMovieLabel = UILabel() 29 | 30 | private let disclosureIndicator: UIImageView = { 31 | let disclosureIndicator = UIImageView() 32 | disclosureIndicator.image = UIImage(systemName: "chevron.down") 33 | disclosureIndicator.contentMode = .scaleAspectFit 34 | disclosureIndicator.preferredSymbolConfiguration = .init(textStyle: .body, scale: .small) 35 | return disclosureIndicator 36 | }() 37 | 38 | // Stacks 39 | private lazy var rootStack: UIStackView = { 40 | let rootStack = UIStackView(arrangedSubviews: [labelStack, disclosureIndicator]) 41 | rootStack.alignment = .top 42 | rootStack.distribution = .fillProportionally 43 | return rootStack 44 | }() 45 | private lazy var labelStack: UIStackView = { 46 | let labelStack = UIStackView(arrangedSubviews: [ 47 | nameLabel, 48 | ageLabel, 49 | favoriteColorLabel, 50 | favoriteMovieLabel, 51 | ]) 52 | labelStack.axis = .vertical 53 | labelStack.spacing = padding 54 | return labelStack 55 | }() 56 | 57 | // Constraints 58 | private var closedConstraint: NSLayoutConstraint? 59 | private var openConstraint: NSLayoutConstraint? 60 | 61 | // Layout 62 | private let padding: CGFloat = 8 63 | private let cornerRadius: CGFloat = 8 64 | 65 | // MARK: - Init 66 | 67 | override init(frame: CGRect) { 68 | super.init(frame: frame) 69 | setUp() 70 | } 71 | 72 | required init?(coder aDecoder: NSCoder) { 73 | super.init(coder: aDecoder) 74 | setUp() 75 | } 76 | 77 | // MARK: - Private Methods 78 | 79 | private func setUp() { 80 | backgroundColor = .systemGray6 81 | clipsToBounds = true 82 | layer.cornerRadius = cornerRadius 83 | 84 | contentView.addSubview(rootStack) 85 | contentView.translatesAutoresizingMaskIntoConstraints = false 86 | rootStack.translatesAutoresizingMaskIntoConstraints = false 87 | 88 | setUpConstraints() 89 | updateAppearance() 90 | } 91 | 92 | private func setUpConstraints() { 93 | NSLayoutConstraint.activate([ 94 | contentView.topAnchor.constraint(equalTo: topAnchor), 95 | contentView.leadingAnchor.constraint(equalTo: leadingAnchor), 96 | contentView.trailingAnchor.constraint(equalTo: trailingAnchor), 97 | contentView.bottomAnchor.constraint(equalTo: bottomAnchor), 98 | rootStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding), 99 | rootStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), 100 | rootStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding), 101 | ]) 102 | 103 | // We need constraints that define the height of the cell when closed and when open 104 | // to allow for animating between the two states. 105 | closedConstraint = 106 | nameLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding) 107 | closedConstraint?.priority = .defaultLow // use low priority so stack stays pinned to top of cell 108 | 109 | openConstraint = 110 | favoriteMovieLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding) 111 | openConstraint?.priority = .defaultLow 112 | } 113 | 114 | private func updateContent() { 115 | guard let person = person else { return } 116 | nameLabel.text = person.name 117 | ageLabel.text = "Age: \(person.age)" 118 | favoriteColorLabel.text = "Favorite color: \(person.favoriteColor)" 119 | favoriteMovieLabel.text = "Favorite movie: \(person.favoriteMovie)" 120 | } 121 | 122 | /// Updates the views to reflect changes in selection 123 | private func updateAppearance() { 124 | closedConstraint?.isActive = !isSelected 125 | openConstraint?.isActive = isSelected 126 | 127 | UIView.animate(withDuration: 0.3) { // 0.3 seconds matches collection view animation 128 | // Set the rotation just under 180º so that it rotates back the same way 129 | let upsideDown = CGAffineTransform(rotationAngle: .pi * 0.999 ) 130 | self.disclosureIndicator.transform = self.isSelected ? upsideDown :.identity 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Platform](https://img.shields.io/badge/platform-iOS%2013.0-lightgrey)](https://developer.apple.com) [![Swift Version](https://img.shields.io/badge/swift-5.2-orange.svg)](https://swift.org/) 2 | 3 | # Expanding Collection View Cell 4 | 5 | This is a sample project demonstrating how to set up a collection view cell and collection view controller to allow the cells to animate open and closed. The technique used here could also be used to do any number of other animations in the cell upon selection. The process is quite simple once you know how to do it, but can be a bit tricky trying to figure it out the first time around. 6 | 7 | This project is set up using a diffable data source and compositional layout for the collection view on the master branch. However, you can also [check out a branch here](https://github.com/swift-student/ExpandingCollectionViewCell/tree/traditional-data-source-flow-layout) that uses a traditional collection view data source and flow layout. 8 | 9 | # Demo 10 | 11 | ![Demo](Demo.gif) 12 | 13 | # Key Points 14 | 15 | ### Cell 16 | 17 | When setting up your constraints, create properties for any constraints that need to be modified or activated/deactivated in order to open or close the cell: 18 | 19 | ``` swift 20 | private var closedConstraint: NSLayoutConstraint? 21 | private var openConstraint: NSLayoutConstraint? 22 | ``` 23 | 24 | Then take care to set up your constraints so that they properly define the height of your cell, and use priority to make sure your content stays where you want it when the cell expands and contracts: 25 | 26 | ``` swift 27 | NSLayoutConstraint.activate([ 28 | contentView.topAnchor.constraint(equalTo: topAnchor), 29 | contentView.leadingAnchor.constraint(equalTo: leadingAnchor), 30 | contentView.trailingAnchor.constraint(equalTo: trailingAnchor), 31 | contentView.bottomAnchor.constraint(equalTo: bottomAnchor), 32 | rootStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding), 33 | rootStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), 34 | rootStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding), 35 | ]) 36 | 37 | // We need constraints that define the height of the cell when closed and when open 38 | // to allow for animating between the two states. 39 | closedConstraint = 40 | nameLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding) 41 | closedConstraint?.priority = .defaultLow // use low priority so stack stays pinned to top of cell 42 | 43 | openConstraint = 44 | favoriteMovieLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding) 45 | openConstraint?.priority = .defaultLow 46 | ``` 47 | 48 | Also, don't forget to set those `translatesAutoresizingMasksIntoConstraints` to false: 49 | 50 | ``` swift 51 | contentView.translatesAutoresizingMaskIntoConstraints = false 52 | rootStack.translatesAutoresizingMaskIntoConstraints = false 53 | ``` 54 | 55 | 56 | 57 | In order to modify the cell's appearance when it is selected or deselected, use a `didSet` on the `isSelected` property of the cell to call an update method: 58 | 59 | ``` swift 60 | override var isSelected: Bool { didSet { updateAppearance() } } 61 | ``` 62 | 63 | In the update method, modify the properties you would like to change. I found that constraints are properly animated in combination with the technique I used in the collection view delegate. However, other things such as transform must be explicitly animated in order to properly animate in all circumstances: 64 | 65 | ``` swift 66 | /// Updates the views to reflect changes in selection 67 | private func updateAppearance() { 68 | closedConstraint?.isActive = !isSelected 69 | openConstraint?.isActive = isSelected 70 | 71 | UIView.animate(withDuration: 0.3) { // 0.3 seconds matches collection view animation 72 | // Set the rotation just under 180º so that it rotates back the same way 73 | let upsideDown = CGAffineTransform(rotationAngle: .pi * 0.999 ) 74 | self.disclosureIndicator.transform = self.isSelected ? upsideDown :.identity 75 | } 76 | } 77 | ``` 78 | 79 | ### Collection View Layout 80 | 81 | When creating a `UICollectionViewCompositionalLayout`, use an estimated dimension for any dimensions that you want to be defined by the cell. Do so in both the item and group size. An easy way to do this is to use one size for both of them: 82 | 83 | ``` swift 84 | // The item and group will share this size to allow for automatic sizing of the cell's height 85 | let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), 86 | heightDimension: .estimated(50)) 87 | 88 | let item = NSCollectionLayoutItem(layoutSize: itemSize) 89 | 90 | let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, 91 | subitems: [item]) 92 | ``` 93 | 94 | ### Collection View Delegate 95 | 96 | In order to support deselecting the currently selected cell, implement `shouldSelectItemAt` instead of `didSelectItemAt`. Then in this method, manually select or deselect the cell. After doing so, refresh the data source by reapplying the current snapshot: 97 | 98 | ``` swift 99 | extension PeopleViewController: UICollectionViewDelegate { 100 | func collectionView(_ collectionView: UICollectionView, 101 | shouldSelectItemAt indexPath: IndexPath) -> Bool { 102 | guard let dataSource = dataSource else { return false } 103 | 104 | // Allows for closing an already open cell 105 | if collectionView.indexPathsForSelectedItems?.contains(indexPath) ?? false { 106 | collectionView.deselectItem(at: indexPath, animated: true) 107 | } else { 108 | collectionView.selectItem(at: indexPath, animated: true, scrollPosition: []) 109 | } 110 | 111 | dataSource.refresh() 112 | 113 | return false // The selecting or deselecting is already performed above 114 | } 115 | } 116 | 117 | extension UICollectionViewDiffableDataSource { 118 | /// Reapplies the current snapshot to the data source, animating the differences. 119 | /// - Parameters: 120 | /// - completion: A closure to be called on completion of reapplying the snapshot. 121 | func refresh(completion: (() -> Void)? = nil) { 122 | self.apply(self.snapshot(), animatingDifferences: true, completion: completion) 123 | } 124 | } 125 | ``` 126 | 127 | 128 | 129 | --------------------------------------------------------------------------------