├── .gitignore ├── ExpandableTableView.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── lucasnascimento.xcuserdatad │ │ └── UserInterfaceState.xcuserstate └── xcuserdata │ ├── l.b.do.nascimento.xcuserdatad │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ │ └── xcschememanagement.plist │ └── lucasnascimento.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── ExpandableTableView ├── .DS_Store ├── Component │ ├── ExpandableAbstractions.swift │ └── ExpandableTableView.swift ├── Example │ ├── Model │ │ ├── FAQItemsModel.swift │ │ ├── FAQModel.swift │ │ └── QuestionAnswerModel.swift │ ├── Presenter │ │ └── FAQPresenter.swift │ ├── Service │ │ └── FAQService.swift │ ├── Utils │ │ ├── JSONHelper.swift │ │ ├── ReusableView.swift │ │ ├── ScrollableStack.swift │ │ ├── UITableView+ReusableView.swift │ │ ├── UIView+ReusableView.swift │ │ └── ViewCodable.swift │ └── View │ │ ├── Cells │ │ ├── FAQHeaderViewCell.swift │ │ └── FAQViewCell.swift │ │ ├── FAQView.swift │ │ ├── FAQViewController.swift │ │ └── Header │ │ └── FAQHeaderView.swift └── SupportingFiles │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── expand-arrow.imageset │ │ ├── Contents.json │ │ └── expand-arrow.png │ └── expand-button.imageset │ │ ├── Contents.json │ │ └── expand-button.png │ ├── Base.lproj │ └── LaunchScreen.storyboard │ ├── FAQs.json │ └── Info.plist ├── README.md └── gif └── example.gif /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by http://www.gitignore.io 2 | 3 | ### OSX ### 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear on external disk 16 | .Spotlight-V100 17 | .Trashes 18 | 19 | # Directories potentially created on remote AFP share 20 | .AppleDB 21 | .AppleDesktop 22 | Network Trash Folder 23 | Temporary Items 24 | .apdisk 25 | 26 | 27 | ### Swift ### 28 | # Xcode 29 | # 30 | build/ 31 | *.pbxuser 32 | !default.pbxuser 33 | *.mode1v3 34 | !default.mode1v3 35 | *.mode2v3 36 | !default.mode2v3 37 | *.perspectivev3 38 | !default.perspectivev3 39 | xcuserdata 40 | *.xccheckout 41 | *.moved-aside 42 | DerivedData 43 | *.hmap 44 | *.ipa 45 | *.xcuserstate 46 | 47 | # CocoaPods 48 | # 49 | # We recommend against adding the Pods directory to your .gitignore. However 50 | # you should judge for yourself, the pros and cons are mentioned at: 51 | # http://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control 52 | # 53 | # Pods/ 54 | 55 | 56 | ### Xcode ### 57 | build/ 58 | *.pbxuser 59 | !default.pbxuser 60 | *.mode1v3 61 | !default.mode1v3 62 | *.mode2v3 63 | !default.mode2v3 64 | *.perspectivev3 65 | !default.perspectivev3 66 | xcuserdata 67 | *.xccheckout 68 | *.moved-aside 69 | DerivedData 70 | *.xcuserstate -------------------------------------------------------------------------------- /ExpandableTableView.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 6E6206622129E3DE007AFA70 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6206612129E3DE007AFA70 /* AppDelegate.swift */; }; 11 | 6E6206692129E3E2007AFA70 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6E6206682129E3E2007AFA70 /* Assets.xcassets */; }; 12 | 6E62066C2129E3E2007AFA70 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6E62066A2129E3E2007AFA70 /* LaunchScreen.storyboard */; }; 13 | 6E6206762129E49B007AFA70 /* FAQModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6206752129E49B007AFA70 /* FAQModel.swift */; }; 14 | 6E6206862129E96B007AFA70 /* ViewCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6206812129E96B007AFA70 /* ViewCodable.swift */; }; 15 | 6E6206872129E96B007AFA70 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6206822129E96B007AFA70 /* UITableView+ReusableView.swift */; }; 16 | 6E6206882129E96B007AFA70 /* UIView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6206832129E96B007AFA70 /* UIView+ReusableView.swift */; }; 17 | 6E6206892129E96B007AFA70 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6206842129E96B007AFA70 /* ReusableView.swift */; }; 18 | B326075B251A8FBA0075B99F /* ExpandableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B326075A251A8FBA0075B99F /* ExpandableTableView.swift */; }; 19 | B33987E3251971C000935D79 /* ScrollableStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33987E2251971C000935D79 /* ScrollableStack.swift */; }; 20 | B33987ED2519902100935D79 /* ExpandableAbstractions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33987E82519902100935D79 /* ExpandableAbstractions.swift */; }; 21 | B35851F32517F06500259FE0 /* FAQs.json in Resources */ = {isa = PBXBuildFile; fileRef = B35851F22517F06500259FE0 /* FAQs.json */; }; 22 | B35851F52517F32200259FE0 /* FAQViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35851F42517F32200259FE0 /* FAQViewController.swift */; }; 23 | B35851FD2517F34F00259FE0 /* FAQPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35851FC2517F34F00259FE0 /* FAQPresenter.swift */; }; 24 | B35851FF2517F40400259FE0 /* JSONHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35851FE2517F40400259FE0 /* JSONHelper.swift */; }; 25 | B3BB4647252FBB3600B676C5 /* FAQItemsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BB4646252FBB3600B676C5 /* FAQItemsModel.swift */; }; 26 | B3BB4649252FBB9100B676C5 /* FAQService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BB4648252FBB9100B676C5 /* FAQService.swift */; }; 27 | B3BB464F252FBD5600B676C5 /* FAQView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BB464E252FBD5600B676C5 /* FAQView.swift */; }; 28 | B3BB4653252FBD7D00B676C5 /* FAQHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BB4652252FBD7D00B676C5 /* FAQHeaderView.swift */; }; 29 | B3BB4655252FBD9000B676C5 /* FAQHeaderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BB4654252FBD9000B676C5 /* FAQHeaderViewCell.swift */; }; 30 | B3BB4657252FBDA000B676C5 /* FAQViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BB4656252FBDA000B676C5 /* FAQViewCell.swift */; }; 31 | B3BB4659252FBEC000B676C5 /* QuestionAnswerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BB4658252FBEC000B676C5 /* QuestionAnswerModel.swift */; }; 32 | /* End PBXBuildFile section */ 33 | 34 | /* Begin PBXFileReference section */ 35 | 6E62065E2129E3DE007AFA70 /* ExpandableTableView.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExpandableTableView.app; sourceTree = BUILT_PRODUCTS_DIR; }; 36 | 6E6206612129E3DE007AFA70 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 37 | 6E6206682129E3E2007AFA70 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 38 | 6E62066B2129E3E2007AFA70 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 39 | 6E62066D2129E3E2007AFA70 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 40 | 6E6206752129E49B007AFA70 /* FAQModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FAQModel.swift; sourceTree = ""; }; 41 | 6E6206812129E96B007AFA70 /* ViewCodable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewCodable.swift; sourceTree = ""; }; 42 | 6E6206822129E96B007AFA70 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; 43 | 6E6206832129E96B007AFA70 /* UIView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+ReusableView.swift"; sourceTree = ""; }; 44 | 6E6206842129E96B007AFA70 /* ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; 45 | B326075A251A8FBA0075B99F /* ExpandableTableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExpandableTableView.swift; sourceTree = ""; }; 46 | B33987E2251971C000935D79 /* ScrollableStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollableStack.swift; sourceTree = ""; }; 47 | B33987E82519902100935D79 /* ExpandableAbstractions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExpandableAbstractions.swift; sourceTree = ""; }; 48 | B35851F22517F06500259FE0 /* FAQs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = FAQs.json; sourceTree = ""; }; 49 | B35851F42517F32200259FE0 /* FAQViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FAQViewController.swift; sourceTree = ""; }; 50 | B35851FC2517F34F00259FE0 /* FAQPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FAQPresenter.swift; sourceTree = ""; }; 51 | B35851FE2517F40400259FE0 /* JSONHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONHelper.swift; sourceTree = ""; }; 52 | B3BB4646252FBB3600B676C5 /* FAQItemsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FAQItemsModel.swift; sourceTree = ""; }; 53 | B3BB4648252FBB9100B676C5 /* FAQService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FAQService.swift; sourceTree = ""; }; 54 | B3BB464E252FBD5600B676C5 /* FAQView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FAQView.swift; sourceTree = ""; }; 55 | B3BB4652252FBD7D00B676C5 /* FAQHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FAQHeaderView.swift; sourceTree = ""; }; 56 | B3BB4654252FBD9000B676C5 /* FAQHeaderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FAQHeaderViewCell.swift; sourceTree = ""; }; 57 | B3BB4656252FBDA000B676C5 /* FAQViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FAQViewCell.swift; sourceTree = ""; }; 58 | B3BB4658252FBEC000B676C5 /* QuestionAnswerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionAnswerModel.swift; sourceTree = ""; }; 59 | /* End PBXFileReference section */ 60 | 61 | /* Begin PBXFrameworksBuildPhase section */ 62 | 6E62065B2129E3DE007AFA70 /* Frameworks */ = { 63 | isa = PBXFrameworksBuildPhase; 64 | buildActionMask = 2147483647; 65 | files = ( 66 | ); 67 | runOnlyForDeploymentPostprocessing = 0; 68 | }; 69 | /* End PBXFrameworksBuildPhase section */ 70 | 71 | /* Begin PBXGroup section */ 72 | 6E6206552129E3DE007AFA70 = { 73 | isa = PBXGroup; 74 | children = ( 75 | 6E6206602129E3DE007AFA70 /* ExpandableTableView */, 76 | 6E62065F2129E3DE007AFA70 /* Products */, 77 | ); 78 | sourceTree = ""; 79 | }; 80 | 6E62065F2129E3DE007AFA70 /* Products */ = { 81 | isa = PBXGroup; 82 | children = ( 83 | 6E62065E2129E3DE007AFA70 /* ExpandableTableView.app */, 84 | ); 85 | name = Products; 86 | sourceTree = ""; 87 | }; 88 | 6E6206602129E3DE007AFA70 /* ExpandableTableView */ = { 89 | isa = PBXGroup; 90 | children = ( 91 | B3BB464A252FBD1300B676C5 /* Example */, 92 | B33987E42519902100935D79 /* Component */, 93 | 6E6206792129E893007AFA70 /* SupportingFiles */, 94 | ); 95 | path = ExpandableTableView; 96 | sourceTree = ""; 97 | }; 98 | 6E6206792129E893007AFA70 /* SupportingFiles */ = { 99 | isa = PBXGroup; 100 | children = ( 101 | 6E6206612129E3DE007AFA70 /* AppDelegate.swift */, 102 | 6E6206682129E3E2007AFA70 /* Assets.xcassets */, 103 | 6E62066A2129E3E2007AFA70 /* LaunchScreen.storyboard */, 104 | 6E62066D2129E3E2007AFA70 /* Info.plist */, 105 | B35851F22517F06500259FE0 /* FAQs.json */, 106 | ); 107 | path = SupportingFiles; 108 | sourceTree = ""; 109 | }; 110 | 6E62067A2129E89E007AFA70 /* Model */ = { 111 | isa = PBXGroup; 112 | children = ( 113 | 6E6206752129E49B007AFA70 /* FAQModel.swift */, 114 | B3BB4646252FBB3600B676C5 /* FAQItemsModel.swift */, 115 | B3BB4658252FBEC000B676C5 /* QuestionAnswerModel.swift */, 116 | ); 117 | path = Model; 118 | sourceTree = ""; 119 | }; 120 | 6E62067B2129E8A6007AFA70 /* View */ = { 121 | isa = PBXGroup; 122 | children = ( 123 | B3BB4650252FBD6A00B676C5 /* Header */, 124 | B3BB4651252FBD7200B676C5 /* Cells */, 125 | B35851F42517F32200259FE0 /* FAQViewController.swift */, 126 | B3BB464E252FBD5600B676C5 /* FAQView.swift */, 127 | ); 128 | path = View; 129 | sourceTree = ""; 130 | }; 131 | 6E62067E2129E96B007AFA70 /* Utils */ = { 132 | isa = PBXGroup; 133 | children = ( 134 | 6E6206812129E96B007AFA70 /* ViewCodable.swift */, 135 | 6E6206822129E96B007AFA70 /* UITableView+ReusableView.swift */, 136 | 6E6206832129E96B007AFA70 /* UIView+ReusableView.swift */, 137 | 6E6206842129E96B007AFA70 /* ReusableView.swift */, 138 | B35851FE2517F40400259FE0 /* JSONHelper.swift */, 139 | B33987E2251971C000935D79 /* ScrollableStack.swift */, 140 | ); 141 | path = Utils; 142 | sourceTree = ""; 143 | }; 144 | B33987E42519902100935D79 /* Component */ = { 145 | isa = PBXGroup; 146 | children = ( 147 | B326075A251A8FBA0075B99F /* ExpandableTableView.swift */, 148 | B33987E82519902100935D79 /* ExpandableAbstractions.swift */, 149 | ); 150 | path = Component; 151 | sourceTree = ""; 152 | }; 153 | B3BB464A252FBD1300B676C5 /* Example */ = { 154 | isa = PBXGroup; 155 | children = ( 156 | B3BB464C252FBD2800B676C5 /* Service */, 157 | B3BB464B252FBD2200B676C5 /* Presenter */, 158 | 6E62067B2129E8A6007AFA70 /* View */, 159 | 6E62067A2129E89E007AFA70 /* Model */, 160 | 6E62067E2129E96B007AFA70 /* Utils */, 161 | ); 162 | path = Example; 163 | sourceTree = ""; 164 | }; 165 | B3BB464B252FBD2200B676C5 /* Presenter */ = { 166 | isa = PBXGroup; 167 | children = ( 168 | B35851FC2517F34F00259FE0 /* FAQPresenter.swift */, 169 | ); 170 | path = Presenter; 171 | sourceTree = ""; 172 | }; 173 | B3BB464C252FBD2800B676C5 /* Service */ = { 174 | isa = PBXGroup; 175 | children = ( 176 | B3BB4648252FBB9100B676C5 /* FAQService.swift */, 177 | ); 178 | path = Service; 179 | sourceTree = ""; 180 | }; 181 | B3BB4650252FBD6A00B676C5 /* Header */ = { 182 | isa = PBXGroup; 183 | children = ( 184 | B3BB4652252FBD7D00B676C5 /* FAQHeaderView.swift */, 185 | ); 186 | path = Header; 187 | sourceTree = ""; 188 | }; 189 | B3BB4651252FBD7200B676C5 /* Cells */ = { 190 | isa = PBXGroup; 191 | children = ( 192 | B3BB4654252FBD9000B676C5 /* FAQHeaderViewCell.swift */, 193 | B3BB4656252FBDA000B676C5 /* FAQViewCell.swift */, 194 | ); 195 | path = Cells; 196 | sourceTree = ""; 197 | }; 198 | /* End PBXGroup section */ 199 | 200 | /* Begin PBXNativeTarget section */ 201 | 6E62065D2129E3DE007AFA70 /* ExpandableTableView */ = { 202 | isa = PBXNativeTarget; 203 | buildConfigurationList = 6E6206702129E3E2007AFA70 /* Build configuration list for PBXNativeTarget "ExpandableTableView" */; 204 | buildPhases = ( 205 | 6E62065A2129E3DE007AFA70 /* Sources */, 206 | 6E62065B2129E3DE007AFA70 /* Frameworks */, 207 | 6E62065C2129E3DE007AFA70 /* Resources */, 208 | ); 209 | buildRules = ( 210 | ); 211 | dependencies = ( 212 | ); 213 | name = ExpandableTableView; 214 | productName = ExpandableTableView; 215 | productReference = 6E62065E2129E3DE007AFA70 /* ExpandableTableView.app */; 216 | productType = "com.apple.product-type.application"; 217 | }; 218 | /* End PBXNativeTarget section */ 219 | 220 | /* Begin PBXProject section */ 221 | 6E6206562129E3DE007AFA70 /* Project object */ = { 222 | isa = PBXProject; 223 | attributes = { 224 | LastSwiftUpdateCheck = 0940; 225 | LastUpgradeCheck = 0940; 226 | ORGANIZATIONNAME = "Lucas Nascimento"; 227 | TargetAttributes = { 228 | 6E62065D2129E3DE007AFA70 = { 229 | CreatedOnToolsVersion = 9.4.1; 230 | LastSwiftMigration = 1130; 231 | }; 232 | }; 233 | }; 234 | buildConfigurationList = 6E6206592129E3DE007AFA70 /* Build configuration list for PBXProject "ExpandableTableView" */; 235 | compatibilityVersion = "Xcode 9.3"; 236 | developmentRegion = en; 237 | hasScannedForEncodings = 0; 238 | knownRegions = ( 239 | en, 240 | Base, 241 | ); 242 | mainGroup = 6E6206552129E3DE007AFA70; 243 | productRefGroup = 6E62065F2129E3DE007AFA70 /* Products */; 244 | projectDirPath = ""; 245 | projectRoot = ""; 246 | targets = ( 247 | 6E62065D2129E3DE007AFA70 /* ExpandableTableView */, 248 | ); 249 | }; 250 | /* End PBXProject section */ 251 | 252 | /* Begin PBXResourcesBuildPhase section */ 253 | 6E62065C2129E3DE007AFA70 /* Resources */ = { 254 | isa = PBXResourcesBuildPhase; 255 | buildActionMask = 2147483647; 256 | files = ( 257 | B35851F32517F06500259FE0 /* FAQs.json in Resources */, 258 | 6E62066C2129E3E2007AFA70 /* LaunchScreen.storyboard in Resources */, 259 | 6E6206692129E3E2007AFA70 /* Assets.xcassets in Resources */, 260 | ); 261 | runOnlyForDeploymentPostprocessing = 0; 262 | }; 263 | /* End PBXResourcesBuildPhase section */ 264 | 265 | /* Begin PBXSourcesBuildPhase section */ 266 | 6E62065A2129E3DE007AFA70 /* Sources */ = { 267 | isa = PBXSourcesBuildPhase; 268 | buildActionMask = 2147483647; 269 | files = ( 270 | B3BB4655252FBD9000B676C5 /* FAQHeaderViewCell.swift in Sources */, 271 | 6E6206892129E96B007AFA70 /* ReusableView.swift in Sources */, 272 | 6E6206762129E49B007AFA70 /* FAQModel.swift in Sources */, 273 | B3BB4647252FBB3600B676C5 /* FAQItemsModel.swift in Sources */, 274 | B3BB4649252FBB9100B676C5 /* FAQService.swift in Sources */, 275 | 6E6206872129E96B007AFA70 /* UITableView+ReusableView.swift in Sources */, 276 | B3BB4659252FBEC000B676C5 /* QuestionAnswerModel.swift in Sources */, 277 | B33987ED2519902100935D79 /* ExpandableAbstractions.swift in Sources */, 278 | B35851FD2517F34F00259FE0 /* FAQPresenter.swift in Sources */, 279 | 6E6206622129E3DE007AFA70 /* AppDelegate.swift in Sources */, 280 | B35851F52517F32200259FE0 /* FAQViewController.swift in Sources */, 281 | B326075B251A8FBA0075B99F /* ExpandableTableView.swift in Sources */, 282 | B3BB4653252FBD7D00B676C5 /* FAQHeaderView.swift in Sources */, 283 | B35851FF2517F40400259FE0 /* JSONHelper.swift in Sources */, 284 | B3BB4657252FBDA000B676C5 /* FAQViewCell.swift in Sources */, 285 | 6E6206882129E96B007AFA70 /* UIView+ReusableView.swift in Sources */, 286 | B33987E3251971C000935D79 /* ScrollableStack.swift in Sources */, 287 | B3BB464F252FBD5600B676C5 /* FAQView.swift in Sources */, 288 | 6E6206862129E96B007AFA70 /* ViewCodable.swift in Sources */, 289 | ); 290 | runOnlyForDeploymentPostprocessing = 0; 291 | }; 292 | /* End PBXSourcesBuildPhase section */ 293 | 294 | /* Begin PBXVariantGroup section */ 295 | 6E62066A2129E3E2007AFA70 /* LaunchScreen.storyboard */ = { 296 | isa = PBXVariantGroup; 297 | children = ( 298 | 6E62066B2129E3E2007AFA70 /* Base */, 299 | ); 300 | name = LaunchScreen.storyboard; 301 | sourceTree = ""; 302 | }; 303 | /* End PBXVariantGroup section */ 304 | 305 | /* Begin XCBuildConfiguration section */ 306 | 6E62066E2129E3E2007AFA70 /* Debug */ = { 307 | isa = XCBuildConfiguration; 308 | buildSettings = { 309 | ALWAYS_SEARCH_USER_PATHS = NO; 310 | CLANG_ANALYZER_NONNULL = YES; 311 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 312 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 313 | CLANG_CXX_LIBRARY = "libc++"; 314 | CLANG_ENABLE_MODULES = YES; 315 | CLANG_ENABLE_OBJC_ARC = YES; 316 | CLANG_ENABLE_OBJC_WEAK = YES; 317 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 318 | CLANG_WARN_BOOL_CONVERSION = YES; 319 | CLANG_WARN_COMMA = YES; 320 | CLANG_WARN_CONSTANT_CONVERSION = YES; 321 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 322 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 323 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 324 | CLANG_WARN_EMPTY_BODY = YES; 325 | CLANG_WARN_ENUM_CONVERSION = YES; 326 | CLANG_WARN_INFINITE_RECURSION = YES; 327 | CLANG_WARN_INT_CONVERSION = YES; 328 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 329 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 330 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 331 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 332 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 333 | CLANG_WARN_STRICT_PROTOTYPES = YES; 334 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 335 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 336 | CLANG_WARN_UNREACHABLE_CODE = YES; 337 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 338 | CODE_SIGN_IDENTITY = "iPhone Developer"; 339 | COPY_PHASE_STRIP = NO; 340 | DEBUG_INFORMATION_FORMAT = dwarf; 341 | ENABLE_STRICT_OBJC_MSGSEND = YES; 342 | ENABLE_TESTABILITY = YES; 343 | GCC_C_LANGUAGE_STANDARD = gnu11; 344 | GCC_DYNAMIC_NO_PIC = NO; 345 | GCC_NO_COMMON_BLOCKS = YES; 346 | GCC_OPTIMIZATION_LEVEL = 0; 347 | GCC_PREPROCESSOR_DEFINITIONS = ( 348 | "DEBUG=1", 349 | "$(inherited)", 350 | ); 351 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 352 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 353 | GCC_WARN_UNDECLARED_SELECTOR = YES; 354 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 355 | GCC_WARN_UNUSED_FUNCTION = YES; 356 | GCC_WARN_UNUSED_VARIABLE = YES; 357 | IPHONEOS_DEPLOYMENT_TARGET = 11.4; 358 | MTL_ENABLE_DEBUG_INFO = YES; 359 | ONLY_ACTIVE_ARCH = YES; 360 | SDKROOT = iphoneos; 361 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 362 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 363 | }; 364 | name = Debug; 365 | }; 366 | 6E62066F2129E3E2007AFA70 /* Release */ = { 367 | isa = XCBuildConfiguration; 368 | buildSettings = { 369 | ALWAYS_SEARCH_USER_PATHS = NO; 370 | CLANG_ANALYZER_NONNULL = YES; 371 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 372 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 373 | CLANG_CXX_LIBRARY = "libc++"; 374 | CLANG_ENABLE_MODULES = YES; 375 | CLANG_ENABLE_OBJC_ARC = YES; 376 | CLANG_ENABLE_OBJC_WEAK = YES; 377 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 378 | CLANG_WARN_BOOL_CONVERSION = YES; 379 | CLANG_WARN_COMMA = YES; 380 | CLANG_WARN_CONSTANT_CONVERSION = YES; 381 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 382 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 383 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 384 | CLANG_WARN_EMPTY_BODY = YES; 385 | CLANG_WARN_ENUM_CONVERSION = YES; 386 | CLANG_WARN_INFINITE_RECURSION = YES; 387 | CLANG_WARN_INT_CONVERSION = YES; 388 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 389 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 390 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 391 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 392 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 393 | CLANG_WARN_STRICT_PROTOTYPES = YES; 394 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 395 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 396 | CLANG_WARN_UNREACHABLE_CODE = YES; 397 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 398 | CODE_SIGN_IDENTITY = "iPhone Developer"; 399 | COPY_PHASE_STRIP = NO; 400 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 401 | ENABLE_NS_ASSERTIONS = NO; 402 | ENABLE_STRICT_OBJC_MSGSEND = YES; 403 | GCC_C_LANGUAGE_STANDARD = gnu11; 404 | GCC_NO_COMMON_BLOCKS = YES; 405 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 406 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 407 | GCC_WARN_UNDECLARED_SELECTOR = YES; 408 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 409 | GCC_WARN_UNUSED_FUNCTION = YES; 410 | GCC_WARN_UNUSED_VARIABLE = YES; 411 | IPHONEOS_DEPLOYMENT_TARGET = 11.4; 412 | MTL_ENABLE_DEBUG_INFO = NO; 413 | SDKROOT = iphoneos; 414 | SWIFT_COMPILATION_MODE = wholemodule; 415 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 416 | VALIDATE_PRODUCT = YES; 417 | }; 418 | name = Release; 419 | }; 420 | 6E6206712129E3E2007AFA70 /* Debug */ = { 421 | isa = XCBuildConfiguration; 422 | buildSettings = { 423 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 424 | CODE_SIGN_STYLE = Automatic; 425 | INFOPLIST_FILE = "$(SRCROOT)/ExpandableTableView/SupportingFiles/Info.plist"; 426 | IPHONEOS_DEPLOYMENT_TARGET = 11.0; 427 | LD_RUNPATH_SEARCH_PATHS = ( 428 | "$(inherited)", 429 | "@executable_path/Frameworks", 430 | ); 431 | PRODUCT_BUNDLE_IDENTIFIER = com.ExpandableTableView; 432 | PRODUCT_NAME = "$(TARGET_NAME)"; 433 | SWIFT_VERSION = 5.0; 434 | TARGETED_DEVICE_FAMILY = "1,2"; 435 | }; 436 | name = Debug; 437 | }; 438 | 6E6206722129E3E2007AFA70 /* Release */ = { 439 | isa = XCBuildConfiguration; 440 | buildSettings = { 441 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 442 | CODE_SIGN_STYLE = Automatic; 443 | INFOPLIST_FILE = "$(SRCROOT)/ExpandableTableView/SupportingFiles/Info.plist"; 444 | IPHONEOS_DEPLOYMENT_TARGET = 11.0; 445 | LD_RUNPATH_SEARCH_PATHS = ( 446 | "$(inherited)", 447 | "@executable_path/Frameworks", 448 | ); 449 | PRODUCT_BUNDLE_IDENTIFIER = com.ExpandableTableView; 450 | PRODUCT_NAME = "$(TARGET_NAME)"; 451 | SWIFT_VERSION = 5.0; 452 | TARGETED_DEVICE_FAMILY = "1,2"; 453 | }; 454 | name = Release; 455 | }; 456 | /* End XCBuildConfiguration section */ 457 | 458 | /* Begin XCConfigurationList section */ 459 | 6E6206592129E3DE007AFA70 /* Build configuration list for PBXProject "ExpandableTableView" */ = { 460 | isa = XCConfigurationList; 461 | buildConfigurations = ( 462 | 6E62066E2129E3E2007AFA70 /* Debug */, 463 | 6E62066F2129E3E2007AFA70 /* Release */, 464 | ); 465 | defaultConfigurationIsVisible = 0; 466 | defaultConfigurationName = Release; 467 | }; 468 | 6E6206702129E3E2007AFA70 /* Build configuration list for PBXNativeTarget "ExpandableTableView" */ = { 469 | isa = XCConfigurationList; 470 | buildConfigurations = ( 471 | 6E6206712129E3E2007AFA70 /* Debug */, 472 | 6E6206722129E3E2007AFA70 /* Release */, 473 | ); 474 | defaultConfigurationIsVisible = 0; 475 | defaultConfigurationName = Release; 476 | }; 477 | /* End XCConfigurationList section */ 478 | }; 479 | rootObject = 6E6206562129E3DE007AFA70 /* Project object */; 480 | } 481 | -------------------------------------------------------------------------------- /ExpandableTableView.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ExpandableTableView.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ExpandableTableView.xcodeproj/project.xcworkspace/xcuserdata/lucasnascimento.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucabelezal/ExpandableTableView/40bacd351b0213ee926ca685b5346b5b045701f2/ExpandableTableView.xcodeproj/project.xcworkspace/xcuserdata/lucasnascimento.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /ExpandableTableView.xcodeproj/xcuserdata/l.b.do.nascimento.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 21 | 22 | 23 | 25 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /ExpandableTableView.xcodeproj/xcuserdata/l.b.do.nascimento.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | ExpandableTableView.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /ExpandableTableView.xcodeproj/xcuserdata/lucasnascimento.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /ExpandableTableView.xcodeproj/xcuserdata/lucasnascimento.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | ExpandableTableView.xcscheme 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /ExpandableTableView/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucabelezal/ExpandableTableView/40bacd351b0213ee926ca685b5346b5b045701f2/ExpandableTableView/.DS_Store -------------------------------------------------------------------------------- /ExpandableTableView/Component/ExpandableAbstractions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public enum ExpandableActionType { 4 | case expand, collapse 5 | } 6 | 7 | @objc public enum ExpandableState: Int { 8 | case willExpand, willCollapse, didExpand, didCollapse 9 | } 10 | 11 | @objc public protocol ExpandableTableHeaderCell: AnyObject { 12 | func changeState(state: ExpandableState, cellReuse: Bool) 13 | } 14 | 15 | @objc public protocol ExpandableTableDataSource: UITableViewDataSource { 16 | func tableView(_ tableView: ExpandableTableView, canExpandSection section: Int) -> Bool 17 | func tableView(_ tableView: ExpandableTableView, expandableCellForSection section: Int) -> UITableViewCell 18 | } 19 | 20 | @objc public protocol ExpandableTableDelegate: UITableViewDelegate {} 21 | 22 | -------------------------------------------------------------------------------- /ExpandableTableView/Component/ExpandableTableView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class ExpandableTableView: UITableView { 4 | public weak var expandableDataSource: ExpandableTableDataSource? 5 | public weak var expandableDelegate: ExpandableTableDelegate? 6 | 7 | public private(set) var expandedSections: [Int: Bool] = [:] 8 | 9 | public override var dataSource: UITableViewDataSource? { 10 | get { return super.dataSource } 11 | set { 12 | expandableDataSource = newValue as? ExpandableTableDataSource 13 | super.dataSource = self 14 | } 15 | } 16 | 17 | public override var delegate: UITableViewDelegate? { 18 | get { return super.delegate } 19 | set { 20 | expandableDelegate = newValue as? ExpandableTableDelegate 21 | super.delegate = self 22 | } 23 | } 24 | 25 | public override init(frame: CGRect, style: UITableView.Style) { 26 | super.init(frame: frame, style: style) 27 | if expandableDelegate == nil { super.delegate = self } 28 | } 29 | 30 | @available(*, unavailable) 31 | required init?(coder _: NSCoder) { 32 | fatalError("init(coder:) has not been implemented") 33 | } 34 | 35 | // MARK: - Public Methods - 36 | 37 | public func expand(_ section: Int) { 38 | collapseLastExpandedSectionIfNeeded() 39 | animate(withActionType: .expand, forSection: section) 40 | } 41 | 42 | public func collapse(_ section: Int) { 43 | animate(withActionType: .collapse, forSection: section) 44 | } 45 | 46 | // MARK: - Private Methods - 47 | 48 | private func collapseLastExpandedSectionIfNeeded() { 49 | expandedSections.forEach { section in 50 | guard section.value == true else { return } 51 | collapse(section.key) 52 | } 53 | } 54 | 55 | private func animate(withActionType type: ExpandableActionType, forSection section: Int) { 56 | guard canExpand(section) else { return } 57 | 58 | let isSectionExpanded = didExpand(section) 59 | 60 | if (type == .expand) == isSectionExpanded { return } 61 | 62 | assign(section, asExpanded: type == .expand) 63 | startAnimating(tableView: self, withActionType: type, forSection: section) 64 | } 65 | 66 | private func startAnimating(tableView: ExpandableTableView, withActionType type: ExpandableActionType, forSection section: Int) { 67 | let headerCell = cellForRow(at: IndexPath(row: 0, section: section)) 68 | let headerCellConformant = headerCell as? ExpandableTableHeaderCell 69 | 70 | CATransaction.begin() 71 | headerCell?.isUserInteractionEnabled = false 72 | 73 | headerCellConformant?.changeState(state: type == .expand ? .willExpand : .willCollapse, cellReuse: false) 74 | 75 | CATransaction.setCompletionBlock { 76 | headerCellConformant?.changeState(state: type == .expand ? .didExpand : .didCollapse, cellReuse: false) 77 | headerCell?.isUserInteractionEnabled = true 78 | } 79 | 80 | beginUpdates() 81 | let numberOfRowsInSection = expandableDataSource?.tableView(tableView, numberOfRowsInSection: section) 82 | if let sectionRowCount = numberOfRowsInSection, sectionRowCount > 1 { 83 | var indexesToProcess: [IndexPath] = [] 84 | 85 | for row in 1 ..< sectionRowCount { 86 | indexesToProcess.append(IndexPath(row: row, section: section)) 87 | } 88 | 89 | switch type { 90 | case .expand: 91 | insertRows(at: indexesToProcess, with: .fade) 92 | case .collapse: 93 | deleteRows(at: indexesToProcess, with: .fade) 94 | } 95 | } 96 | endUpdates() 97 | CATransaction.commit() 98 | } 99 | 100 | private func canExpand(_ section: Int) -> Bool { 101 | return expandableDataSource?.tableView(self, canExpandSection: section) ?? true 102 | } 103 | 104 | private func didExpand(_ section: Int) -> Bool { 105 | return expandedSections[section] ?? false 106 | } 107 | 108 | private func assign(_ section: Int, asExpanded: Bool) { 109 | expandedSections[section] = asExpanded 110 | } 111 | 112 | // MARK: - Verify Protocol - 113 | 114 | private func verifyProtocol(_ aProtocol: Protocol, contains aSelector: Selector) -> Bool { 115 | return protocol_getMethodDescription(aProtocol, aSelector, true, true).name != nil || 116 | protocol_getMethodDescription(aProtocol, aSelector, false, true).name != nil 117 | } 118 | 119 | public override func responds(to aSelector: Selector!) -> Bool { 120 | if verifyProtocol(UITableViewDataSource.self, contains: aSelector) { 121 | return (super.responds(to: aSelector)) || (expandableDataSource?.responds(to: aSelector) ?? false) 122 | 123 | } else if verifyProtocol(UITableViewDelegate.self, contains: aSelector) { 124 | return (super.responds(to: aSelector)) || (expandableDelegate?.responds(to: aSelector) ?? false) 125 | } 126 | return super.responds(to: aSelector) 127 | } 128 | 129 | public override func forwardingTarget(for aSelector: Selector!) -> Any? { 130 | if verifyProtocol(UITableViewDataSource.self, contains: aSelector) { 131 | return expandableDataSource 132 | 133 | } else if verifyProtocol(UITableViewDelegate.self, contains: aSelector) { 134 | return expandableDelegate 135 | } 136 | return super.forwardingTarget(for: aSelector) 137 | } 138 | } 139 | 140 | // MARK: - UITableViewDataSource - 141 | 142 | extension ExpandableTableView: UITableViewDataSource { 143 | public func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { 144 | let numberOfRows = expandableDataSource?.tableView(self, numberOfRowsInSection: section) ?? 0 145 | 146 | guard canExpand(section) else { return numberOfRows } 147 | guard numberOfRows != 0 else { return 0 } 148 | 149 | return didExpand(section) ? numberOfRows : 1 150 | } 151 | 152 | public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 153 | guard let expandableDataSource = expandableDataSource else { 154 | return UITableViewCell() 155 | } 156 | 157 | guard canExpand(indexPath.section), indexPath.row == 0 else { 158 | return expandableDataSource.tableView(tableView, cellForRowAt: indexPath) 159 | } 160 | 161 | let headerCell = expandableDataSource.tableView(self, expandableCellForSection: indexPath.section) 162 | 163 | guard let headerCellConformant = headerCell as? ExpandableTableHeaderCell else { 164 | return headerCell 165 | } 166 | 167 | if didExpand(indexPath.section) { 168 | headerCellConformant.changeState(state: .willExpand, cellReuse: true) 169 | headerCellConformant.changeState(state: .didExpand, cellReuse: true) 170 | } else { 171 | headerCellConformant.changeState(state: .willCollapse, cellReuse: true) 172 | headerCellConformant.changeState(state: .didCollapse, cellReuse: true) 173 | } 174 | return headerCell 175 | } 176 | } 177 | 178 | // MARK: - UITableViewDelegate - 179 | 180 | extension ExpandableTableView: UITableViewDelegate { 181 | public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 182 | expandableDelegate?.tableView?(tableView, didSelectRowAt: indexPath) 183 | 184 | guard canExpand(indexPath.section), indexPath.row == 0 else { return } 185 | didExpand(indexPath.section) ? collapse(indexPath.section) : expand(indexPath.section) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/Model/FAQItemsModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct FAQItemsModel { 4 | let title: String? 5 | let rows: [String] 6 | let ids: [Int] 7 | } 8 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/Model/FAQModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct FAQModel: Decodable { 4 | let title: String 5 | let section: [FAQSectionModel] 6 | } 7 | 8 | extension FAQModel: Hashable { 9 | static func == (lhs: FAQModel, rhs: FAQModel) -> Bool { 10 | return lhs.title == rhs.title && lhs.section == rhs.section 11 | } 12 | } 13 | 14 | struct FAQSectionModel: Decodable { 15 | let title: String 16 | let questions: [FAQItemModel] 17 | } 18 | 19 | extension FAQSectionModel: Hashable { 20 | static func == (lhs: FAQSectionModel, rhs: FAQSectionModel) -> Bool { 21 | return lhs.title == rhs.title && lhs.questions == rhs.questions 22 | } 23 | } 24 | 25 | struct FAQItemModel: Decodable { 26 | let id: Int 27 | let title: String 28 | } 29 | 30 | extension FAQItemModel: Hashable { 31 | static func == (lhs: FAQItemModel, rhs: FAQItemModel) -> Bool { 32 | return lhs.id == rhs.id && lhs.title == rhs.title 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/Model/QuestionAnswerModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct QuestionAnswerModel { 4 | let navigationTitle: String? 5 | let title: String? 6 | let answerID: Int 7 | } 8 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/Presenter/FAQPresenter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol FAQPresenterProtocol { 4 | func fetchQuestions() 5 | } 6 | 7 | final class FAQPresenter: FAQPresenterProtocol { 8 | weak var view: FAQViewControllerProtocol? 9 | 10 | private let service: FAQServiceProtocol 11 | 12 | init(service: FAQServiceProtocol = FAQService()) { 13 | self.service = service 14 | } 15 | 16 | func fetchQuestions() { 17 | service.fetchQuestions { [weak self] result in 18 | switch result { 19 | case let .success(faqs): 20 | self?.handleSuccessFetchQuestions(faqItems: faqs) 21 | case let .failure(error): 22 | self?.view?.showRetry(error: error) 23 | } 24 | } 25 | } 26 | 27 | private func handleSuccessFetchQuestions(faqItems: [FAQModel]) { 28 | var items: [FAQItemsModel] = [] 29 | 30 | for item in faqItems { 31 | for section in item.section { 32 | var title: String? 33 | if section == item.section.first { 34 | title = item.title 35 | } 36 | items.append( 37 | FAQItemsModel( 38 | title: title, 39 | rows: transformSectionIntoRows(section: section), 40 | ids: [-1] + section.questions.compactMap { $0.id } 41 | ) 42 | ) 43 | } 44 | } 45 | 46 | view?.showQuestions(items: items) 47 | } 48 | 49 | private func transformSectionIntoRows(section: FAQSectionModel) -> [String] { 50 | return [section.title] + section.questions.compactMap { $0.title } 51 | } 52 | } 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/Service/FAQService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol FAQServiceProtocol { 4 | func fetchQuestions(completion: @escaping (Result<[FAQModel], Error>) -> Void) 5 | } 6 | 7 | struct FAQService: FAQServiceProtocol { 8 | func fetchQuestions(completion: @escaping (Result<[FAQModel], Error>) -> Void) { 9 | let jsonData = JSONHelper.getDataFrom(json: "FAQs")! 10 | do { 11 | let faqs = try JSONDecoder().decode([FAQModel].self, from: jsonData) 12 | completion(.success(faqs)) 13 | } catch(let error) { 14 | completion(.failure(error)) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/Utils/JSONHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class JSONHelper { 4 | class func getDataFrom(json file: String) -> Data? { 5 | if let path = Bundle(for: JSONHelper.self).path(forResource: file, ofType: "json") { 6 | do { 7 | let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) 8 | return data 9 | } catch { 10 | fatalError("malformed json") 11 | } 12 | } 13 | fatalError("malformed json") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/Utils/ReusableView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol ReusableView { 4 | static var reuseIdentifier: String { get } 5 | } 6 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/Utils/ScrollableStack.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class ScrollableStack: UIView { 4 | private let scrollView: UIScrollView = { 5 | let view = UIScrollView() 6 | view.contentInsetAdjustmentBehavior = .never 7 | view.showsVerticalScrollIndicator = false 8 | view.contentInset = .zero 9 | return view 10 | }() 11 | 12 | private let stackView: UIStackView = { 13 | let stackView = UIStackView() 14 | stackView.axis = .vertical 15 | stackView.alignment = .fill 16 | stackView.spacing = 0 17 | stackView.distribution = .fill 18 | return stackView 19 | }() 20 | 21 | public override init(frame: CGRect = .zero) { 22 | super.init(frame: frame) 23 | backgroundColor = .cyan 24 | buildViewHierarchy() 25 | } 26 | 27 | @available(*, unavailable) 28 | required init?(coder _: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | public func setScrollView(insets: UIEdgeInsets) { 33 | scrollView.contentInset = insets 34 | } 35 | 36 | public func update(with views: [UIView]) { 37 | removeAllArrangedSubviews() 38 | views.forEach(stackView.addArrangedSubview(_:)) 39 | } 40 | 41 | public func setCustom(space: CGFloat, after viewOfType: UIView.Type) { 42 | if let view = stackView.arrangedSubviews.first( 43 | where: { $0.isKind(of: viewOfType) } 44 | ) { 45 | stackView.setCustomSpacing(space, after: view) 46 | } 47 | } 48 | 49 | private func removeAllArrangedSubviews() { 50 | stackView.arrangedSubviews.forEach { view in 51 | stackView.removeArrangedSubview(view) 52 | view.removeFromSuperview() 53 | } 54 | } 55 | } 56 | 57 | extension ScrollableStack { 58 | public func buildViewHierarchy() { 59 | addSubview(scrollView) 60 | scrollView.addSubview(stackView) 61 | 62 | scrollView.translatesAutoresizingMaskIntoConstraints = false 63 | stackView.translatesAutoresizingMaskIntoConstraints = false 64 | 65 | NSLayoutConstraint.activate([ 66 | scrollView.topAnchor.constraint(equalTo: topAnchor), 67 | scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), 68 | scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), 69 | scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), 70 | ]) 71 | 72 | 73 | NSLayoutConstraint.activate([ 74 | stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), 75 | stackView.topAnchor.constraint(equalTo: scrollView.topAnchor), 76 | stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), 77 | stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), 78 | stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor) 79 | ]) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/Utils/UITableView+ReusableView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol DescribeProtocol: AnyObject {} 4 | 5 | extension DescribeProtocol where Self: NSObject { 6 | public static var identifier: String { 7 | return String(describing: self) 8 | } 9 | } 10 | 11 | extension NSObject: DescribeProtocol {} 12 | 13 | extension UITableView { 14 | public func register(_: T.Type) { 15 | register(T.self, forCellReuseIdentifier: T.identifier) 16 | } 17 | 18 | public func registerHeaderFooterView(_: T.Type) { 19 | register(T.self, forHeaderFooterViewReuseIdentifier: T.identifier) 20 | } 21 | 22 | public func dequeueReusableCell(forIndexPath indexPath: IndexPath) -> T { 23 | guard let cell = dequeueReusableCell(withIdentifier: T.identifier, for: indexPath) as? T else { 24 | fatalError("Could not dequeue cell with identifier: \(T.identifier)") 25 | } 26 | return cell 27 | } 28 | 29 | public func dequeueHeaderFooterView() -> T { 30 | guard let view = dequeueReusableHeaderFooterView(withIdentifier: T.identifier) as? T else { 31 | fatalError("Could not dequeue header or footer view with identifier: \(T.identifier)") 32 | } 33 | return view 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/Utils/UIView+ReusableView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension ReusableView where Self: UIView { 4 | static var reuseIdentifier: String { 5 | return String(describing: self) 6 | } 7 | } 8 | 9 | extension UITableViewCell: ReusableView { } 10 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/Utils/ViewCodable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol ViewCodable { 4 | func setupView() 5 | func configureHierarchy() 6 | func configureConstraints() 7 | func configureView() 8 | } 9 | 10 | extension ViewCodable { 11 | 12 | func setupView() { 13 | configureHierarchy() 14 | configureConstraints() 15 | configureView() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/View/Cells/FAQHeaderViewCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class FAQHeaderViewCell: UITableViewCell, ExpandableTableHeaderCell { 4 | private let titleLabel = UILabel() 5 | private let arrowImageView = UIImageView(image: UIImage(named: "expand-arrow")) 6 | private let horizontalLineView = UIView() 7 | 8 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 9 | super.init(style: style, reuseIdentifier: reuseIdentifier) 10 | setupView() 11 | } 12 | 13 | @available(*, unavailable) 14 | required init?(coder _: NSCoder) { 15 | fatalError("init(coder:) has not been implemented") 16 | } 17 | 18 | func update(title: String?) { 19 | titleLabel.text = title 20 | } 21 | 22 | func changeState(state: ExpandableState, cellReuse: Bool) { 23 | if case .willExpand = state { 24 | arrowUp(animated: !cellReuse) 25 | } else if case .willCollapse = state { 26 | arrowDown(animated: !cellReuse) 27 | } 28 | //horizontalLineView.backgroundColor = state == .willExpand ? .clear : #colorLiteral(red: 0.8862745098, green: 0.8862745098, blue: 0.8862745098, alpha: 1) 29 | } 30 | 31 | private func arrowUp(animated: Bool) { 32 | UIView.animate(withDuration: animated ? 0.3 : 0) { 33 | self.arrowImageView.transform = CGAffineTransform(rotationAngle: CGFloat.pi) 34 | } 35 | } 36 | 37 | private func arrowDown(animated: Bool) { 38 | UIView.animate(withDuration: animated ? 0.3 : 0) { 39 | self.arrowImageView.transform = CGAffineTransform(rotationAngle: 0) 40 | } 41 | } 42 | } 43 | 44 | extension FAQHeaderViewCell: ViewCodable { 45 | func configureHierarchy() { 46 | [titleLabel, arrowImageView, horizontalLineView].forEach(addSubview) 47 | } 48 | 49 | func configureConstraints() { 50 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 51 | arrowImageView.translatesAutoresizingMaskIntoConstraints = false 52 | horizontalLineView.translatesAutoresizingMaskIntoConstraints = false 53 | 54 | let titleLabelConstraints = [ 55 | titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8), 56 | titleLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: 16), 57 | titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), 58 | titleLabel.rightAnchor.constraint(equalTo: arrowImageView.leftAnchor, constant: -16) 59 | ] 60 | NSLayoutConstraint.activate(titleLabelConstraints) 61 | 62 | 63 | let arrowImageViewConstraints = [ 64 | arrowImageView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), 65 | arrowImageView.heightAnchor.constraint(equalToConstant: 16), 66 | arrowImageView.widthAnchor.constraint(equalToConstant: 16), 67 | arrowImageView.rightAnchor.constraint(equalTo: rightAnchor, constant: -16) 68 | ] 69 | NSLayoutConstraint.activate(arrowImageViewConstraints) 70 | 71 | let horizontalLineViewConstraints = [ 72 | horizontalLineView.leftAnchor.constraint(equalTo: leftAnchor), 73 | horizontalLineView.bottomAnchor.constraint(equalTo: bottomAnchor), 74 | horizontalLineView.rightAnchor.constraint(equalTo: rightAnchor), 75 | horizontalLineView.heightAnchor.constraint(equalToConstant: 1) 76 | ] 77 | NSLayoutConstraint.activate(horizontalLineViewConstraints) 78 | } 79 | 80 | func configureView() { 81 | backgroundColor = .white 82 | titleLabel.textColor = .systemBlue 83 | titleLabel.font = .boldSystemFont(ofSize: 14) 84 | horizontalLineView.backgroundColor = #colorLiteral(red: 0.8862745098, green: 0.8862745098, blue: 0.8862745098, alpha: 1) 85 | arrowImageView.contentMode = .scaleAspectFit 86 | selectionStyle = .none 87 | separatorInset = UIEdgeInsets(top: 0, left: UIScreen.main.bounds.size.width, bottom: 0, right: 0) 88 | } 89 | } 90 | 91 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/View/Cells/FAQViewCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class FAQViewCell: UITableViewCell { 4 | private let titleLabel = UILabel() 5 | 6 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 7 | super.init(style: style, reuseIdentifier: reuseIdentifier) 8 | setupView() 9 | } 10 | 11 | @available(*, unavailable) 12 | required init?(coder _: NSCoder) { 13 | fatalError("init(coder:) has not been implemented") 14 | } 15 | 16 | func update(title: String?) { 17 | titleLabel.text = title 18 | } 19 | } 20 | 21 | extension FAQViewCell: ViewCodable { 22 | func configureHierarchy() { 23 | addSubview(titleLabel) 24 | } 25 | 26 | func configureConstraints() { 27 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 28 | let constraints = [ 29 | titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8), 30 | titleLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: 16), 31 | titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), 32 | titleLabel.rightAnchor.constraint(equalTo: rightAnchor, constant: -16) 33 | ] 34 | NSLayoutConstraint.activate(constraints) 35 | } 36 | 37 | func configureView() { 38 | backgroundColor = #colorLiteral(red: 0.9764705882, green: 0.9764705882, blue: 0.9764705882, alpha: 1) 39 | titleLabel.textColor = .darkGray 40 | titleLabel.font = .systemFont(ofSize: 14) 41 | selectionStyle = .none 42 | separatorInset = UIEdgeInsets(top: 0, left: UIScreen.main.bounds.size.width, bottom: 0, right: 0) 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/View/FAQView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol FAQViewDelegate: AnyObject { 4 | func didSelectQuestion(questionAnswer: QuestionAnswerModel) 5 | } 6 | 7 | final class FAQView: UIView { 8 | weak var delegate: FAQViewDelegate? 9 | 10 | private var faqItems: [FAQItemsModel] = [] { 11 | didSet { 12 | expandableTableView.reloadData() 13 | } 14 | } 15 | 16 | private lazy var expandableTableView: ExpandableTableView = { 17 | let tableView = ExpandableTableView() 18 | tableView.register(FAQHeaderViewCell.self) 19 | tableView.register(FAQViewCell.self) 20 | tableView.tableFooterView = UIView() 21 | tableView.delegate = self 22 | tableView.dataSource = self 23 | return tableView 24 | }() 25 | 26 | override init(frame: CGRect) { 27 | super.init(frame: frame) 28 | setupView() 29 | } 30 | 31 | @available(*, unavailable) 32 | required init?(coder _: NSCoder) { 33 | fatalError("init(coder:) has not been implemented") 34 | } 35 | 36 | func update(faqItems: [FAQItemsModel]) { 37 | self.faqItems = faqItems 38 | } 39 | 40 | private func isFirstRowAt(indexPath: IndexPath) -> Bool { 41 | guard let firstRow = faqItems[indexPath.section].rows.first else { 42 | return false 43 | } 44 | 45 | return faqItems[indexPath.section].rows[indexPath.row] != firstRow 46 | } 47 | } 48 | 49 | extension FAQView: ViewCodable { 50 | func configureHierarchy() { 51 | addSubview(expandableTableView) 52 | } 53 | 54 | func configureConstraints() { 55 | expandableTableView.translatesAutoresizingMaskIntoConstraints = false 56 | let constraints = [ 57 | expandableTableView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), 58 | expandableTableView.leftAnchor.constraint(equalTo: leftAnchor), 59 | expandableTableView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), 60 | expandableTableView.rightAnchor.constraint(equalTo: rightAnchor) 61 | ] 62 | NSLayoutConstraint.activate(constraints) 63 | } 64 | 65 | func configureView() { 66 | expandableTableView.backgroundColor = .white 67 | expandableTableView.separatorColor = .clear 68 | expandableTableView.separatorStyle = .none 69 | } 70 | } 71 | 72 | extension FAQView: ExpandableTableDataSource { 73 | func numberOfSections(in _: UITableView) -> Int { 74 | return faqItems.count 75 | } 76 | 77 | func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { 78 | return faqItems[section].rows.count 79 | } 80 | 81 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 82 | let cell: FAQViewCell = tableView.dequeueReusableCell(forIndexPath: indexPath) 83 | let title = faqItems[indexPath.section].rows[indexPath.row] 84 | cell.update(title: title) 85 | cell.accessibilityLabel = title 86 | return cell 87 | } 88 | 89 | func tableView(_: ExpandableTableView, canExpandSection _: Int) -> Bool { 90 | return true 91 | } 92 | 93 | func tableView(_ tableView: ExpandableTableView, expandableCellForSection section: Int) -> UITableViewCell { 94 | guard 95 | let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: FAQHeaderViewCell.self)) as? FAQHeaderViewCell 96 | else { return UITableViewCell() } 97 | let title = faqItems[section].rows.first 98 | cell.update(title: title) 99 | return cell 100 | } 101 | } 102 | 103 | extension FAQView: ExpandableTableDelegate { 104 | func tableView(_: UITableView, viewForHeaderInSection section: Int) -> UIView? { 105 | let headerView = FAQHeaderView() 106 | headerView.update(title: faqItems[section].title) 107 | return headerView 108 | } 109 | 110 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 111 | tableView.deselectRow(at: indexPath, animated: false) 112 | guard isFirstRowAt(indexPath: indexPath) else { return } 113 | let sectionItem = faqItems[indexPath.section] 114 | let questionAnswer = QuestionAnswerModel( 115 | navigationTitle: sectionItem.rows.first, 116 | title: sectionItem.rows[indexPath.row], 117 | answerID: sectionItem.ids[indexPath.row] 118 | ) 119 | delegate?.didSelectQuestion(questionAnswer: questionAnswer) 120 | } 121 | 122 | func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat { 123 | return 56 124 | } 125 | 126 | func tableView(_: UITableView, heightForHeaderInSection section: Int) -> CGFloat { 127 | return faqItems[section].title != nil ? 80 : 0 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/View/FAQViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol FAQViewControllerProtocol: AnyObject { 4 | func showQuestions(items: [FAQItemsModel]) 5 | func showRetry(error: Error) 6 | } 7 | 8 | final class FAQViewController: UIViewController { 9 | 10 | private lazy var faqView: FAQView = { 11 | let view = FAQView() 12 | view.delegate = self 13 | return view 14 | }() 15 | 16 | private lazy var retryView: UIView = { 17 | let view = UIView() 18 | view.isHidden = true 19 | return view 20 | }() 21 | 22 | private let presenter: FAQPresenterProtocol 23 | 24 | init(presenter: FAQPresenterProtocol) { 25 | self.presenter = presenter 26 | super.init(nibName: nil, bundle: nil) 27 | hidesBottomBarWhenPushed = true 28 | } 29 | 30 | @available(*, unavailable) 31 | required init?(coder: NSCoder) { 32 | fatalError("init(coder:) has not been implemented") 33 | } 34 | 35 | override func viewDidLoad() { 36 | super.viewDidLoad() 37 | setupView() 38 | presenter.fetchQuestions() 39 | } 40 | 41 | @objc 42 | private func reloadView() { 43 | presenter.fetchQuestions() 44 | } 45 | } 46 | 47 | extension FAQViewController: ViewCodable { 48 | func configureHierarchy() { 49 | [faqView, retryView].forEach(view.addSubview) 50 | } 51 | 52 | func configureConstraints() { 53 | retryView.translatesAutoresizingMaskIntoConstraints = false 54 | let retryViewConstraints = [ 55 | retryView.topAnchor.constraint(equalTo: view.topAnchor), 56 | retryView.leftAnchor.constraint(equalTo: view.leftAnchor), 57 | retryView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 58 | retryView.rightAnchor.constraint(equalTo: view.rightAnchor) 59 | ] 60 | NSLayoutConstraint.activate(retryViewConstraints) 61 | 62 | faqView.translatesAutoresizingMaskIntoConstraints = false 63 | let faqViewConstraints = [ 64 | faqView.topAnchor.constraint(equalTo: view.topAnchor), 65 | faqView.leftAnchor.constraint(equalTo: view.leftAnchor), 66 | faqView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 67 | faqView.rightAnchor.constraint(equalTo: view.rightAnchor) 68 | ] 69 | NSLayoutConstraint.activate(faqViewConstraints) 70 | } 71 | 72 | func configureView() { 73 | title = "Perguntas mais frequentes" 74 | view.backgroundColor = .white 75 | } 76 | } 77 | 78 | extension FAQViewController: FAQViewControllerProtocol { 79 | func showQuestions(items: [FAQItemsModel]) { 80 | retryView.isHidden = true 81 | faqView.update(faqItems: items) 82 | } 83 | 84 | func showRetry(error: Error) { 85 | retryView.isHidden = false 86 | } 87 | } 88 | 89 | extension FAQViewController: FAQViewDelegate { 90 | func didSelectQuestion(questionAnswer: QuestionAnswerModel) { 91 | let viewController = UIViewController() 92 | viewController.title = questionAnswer.title 93 | viewController.view.backgroundColor = .lightGray 94 | navigationController?.pushViewController(viewController, animated: true) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /ExpandableTableView/Example/View/Header/FAQHeaderView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class FAQHeaderView: UIView { 4 | private let titleLabel = UILabel() 5 | private let horizontalLineView = UIView() 6 | 7 | override init(frame: CGRect) { 8 | super.init(frame: frame) 9 | setupView() 10 | } 11 | 12 | @available(*, unavailable) 13 | required init?(coder _: NSCoder) { 14 | fatalError("init(coder:) has not been implemented") 15 | } 16 | 17 | func update(title: String?) { 18 | titleLabel.text = title 19 | } 20 | } 21 | 22 | extension FAQHeaderView: ViewCodable { 23 | func configureHierarchy() { 24 | [titleLabel, horizontalLineView].forEach(addSubview) 25 | } 26 | 27 | func configureConstraints() { 28 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 29 | horizontalLineView.translatesAutoresizingMaskIntoConstraints = false 30 | 31 | let titleLabelConstraints = [ 32 | titleLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: 16), 33 | titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), 34 | titleLabel.rightAnchor.constraint(equalTo: rightAnchor, constant: -16) 35 | ] 36 | NSLayoutConstraint.activate(titleLabelConstraints) 37 | 38 | let horizontalLineViewConstraints = [ 39 | horizontalLineView.heightAnchor.constraint(equalToConstant: 1), 40 | horizontalLineView.leftAnchor.constraint(equalTo: leftAnchor), 41 | horizontalLineView.bottomAnchor.constraint(equalTo: bottomAnchor), 42 | horizontalLineView.rightAnchor.constraint(equalTo: rightAnchor) 43 | ] 44 | NSLayoutConstraint.activate(horizontalLineViewConstraints) 45 | } 46 | 47 | func configureView() { 48 | backgroundColor = .white 49 | titleLabel.textColor = .lightGray 50 | titleLabel.font = .boldSystemFont(ofSize: 12) 51 | horizontalLineView.backgroundColor = #colorLiteral(red: 0.8862745098, green: 0.8862745098, blue: 0.8862745098, alpha: 1) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ExpandableTableView/SupportingFiles/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | 6 | var window: UIWindow? 7 | 8 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 9 | let presenter = FAQPresenter() 10 | let viewController = FAQViewController(presenter: presenter) 11 | presenter.view = viewController 12 | let navigationController = UINavigationController(rootViewController: viewController) 13 | window = UIWindow(frame: UIScreen.main.bounds) 14 | window?.rootViewController = navigationController 15 | window?.makeKeyAndVisible() 16 | return true 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /ExpandableTableView/SupportingFiles/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /ExpandableTableView/SupportingFiles/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /ExpandableTableView/SupportingFiles/Assets.xcassets/expand-arrow.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "expand-arrow.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /ExpandableTableView/SupportingFiles/Assets.xcassets/expand-arrow.imageset/expand-arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucabelezal/ExpandableTableView/40bacd351b0213ee926ca685b5346b5b045701f2/ExpandableTableView/SupportingFiles/Assets.xcassets/expand-arrow.imageset/expand-arrow.png -------------------------------------------------------------------------------- /ExpandableTableView/SupportingFiles/Assets.xcassets/expand-button.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "expand-button.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /ExpandableTableView/SupportingFiles/Assets.xcassets/expand-button.imageset/expand-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucabelezal/ExpandableTableView/40bacd351b0213ee926ca685b5346b5b045701f2/ExpandableTableView/SupportingFiles/Assets.xcassets/expand-button.imageset/expand-button.png -------------------------------------------------------------------------------- /ExpandableTableView/SupportingFiles/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 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /ExpandableTableView/SupportingFiles/FAQs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "SEÇÃO", 4 | "section": [ 5 | { 6 | "title": "Assunto (4 Itens)", 7 | "questions": [ 8 | { 9 | "id": 1, 10 | "title": "Dúvida 1" 11 | }, 12 | { 13 | "id": 2, 14 | "title": "Dúvida 2" 15 | }, 16 | { 17 | "id": 3, 18 | "title": "Dúvida 3" 19 | }, 20 | { 21 | "id": 4, 22 | "title": "Dúvida 4" 23 | } 24 | ] 25 | }, 26 | { 27 | "title": "Assunto (3 Itens)", 28 | "questions": [ 29 | { 30 | "id": 4, 31 | "title": "Dúvida 1" 32 | }, 33 | { 34 | "id": 5, 35 | "title": "Dúvida 2" 36 | }, 37 | { 38 | "id": 6, 39 | "title": "Dúvida 3" 40 | } 41 | ] 42 | } 43 | ] 44 | }, 45 | { 46 | "title": "SEÇÃO 2", 47 | "section": [ 48 | { 49 | "title": "Assunto (5 Itens)", 50 | "questions": [ 51 | { 52 | "id": 17, 53 | "title": "Dúvida 1" 54 | }, 55 | { 56 | "id": 18, 57 | "title": "Dúvida 2" 58 | }, 59 | { 60 | "id": 19, 61 | "title": "Dúvida 3" 62 | }, 63 | { 64 | "id": 20, 65 | "title": "Dúvida 4" 66 | }, 67 | { 68 | "id": 21, 69 | "title": "Dúvida 5" 70 | } 71 | ] 72 | }, 73 | { 74 | "title": "Assunto (3 Itens)", 75 | "questions": [ 76 | { 77 | "id": 22, 78 | "title": "Dúvida 1" 79 | }, 80 | { 81 | "id": 23, 82 | "title": "Dúvida 2" 83 | }, 84 | { 85 | "id": 24, 86 | "title": "Dúvida 3" 87 | } 88 | ] 89 | }, 90 | { 91 | "title": "Assunto (2 Itens)", 92 | "questions": [ 93 | { 94 | "id": 25, 95 | "title": "Dúvida 1" 96 | }, 97 | { 98 | "id": 26, 99 | "title": "Dúvida 2" 100 | } 101 | ] 102 | } 103 | ] 104 | } 105 | ] 106 | 107 | -------------------------------------------------------------------------------- /ExpandableTableView/SupportingFiles/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 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIRequiredDeviceCapabilities 26 | 27 | armv7 28 | 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Expandable Table View 2 | 3 | 4 | ### Copy the files that are in the Component folder 5 | 6 | ``` 7 | ExpandableTableView 8 | ├───Component 9 | │ ├───ExpandableAbstractions.swift 10 | │ └───ExpandableTableView.swift 11 | │ 12 | ``` 13 | 14 | ### How to use 15 | 16 | Instead of using the standard protocols `UITableViewDataSource` and `UITableViewDelegate`, use `ExpandableTableDataSource` and 17 | `ExpandableTableDelegate`. 18 | 19 | See the implementation below used in the class [FAQView](https://github.com/lucabelezal/ExpandableTableView/blob/master/ExpandableTableView/Example/View/FAQView.swift): 20 | 21 | ```swift 22 | private lazy var expandableTableView: ExpandableTableView = { 23 | let tableView = ExpandableTableView() 24 | tableView.register(FAQHeaderViewCell.self) 25 | tableView.register(FAQViewCell.self) 26 | tableView.tableFooterView = UIView() 27 | tableView.delegate = self 28 | tableView.dataSource = self 29 | return tableView 30 | }() 31 | ``` 32 | 33 | ```swift 34 | extension FAQView: ExpandableTableDataSource { 35 | func numberOfSections(in _: UITableView) -> Int { 36 | return faqItems.count 37 | } 38 | 39 | func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { 40 | return faqItems[section].rows.count 41 | } 42 | 43 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 44 | let cell: FAQViewCell = tableView.dequeueReusableCell(forIndexPath: indexPath) 45 | let title = faqItems[indexPath.section].rows[indexPath.row] 46 | cell.update(title: title) 47 | cell.accessibilityLabel = title 48 | return cell 49 | } 50 | 51 | func tableView(_: ExpandableTableView, canExpandSection _: Int) -> Bool { 52 | return true 53 | } 54 | 55 | func tableView(_ tableView: ExpandableTableView, expandableCellForSection section: Int) -> UITableViewCell { 56 | guard 57 | let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: FAQHeaderViewCell.self)) as? FAQHeaderViewCell 58 | else { return UITableViewCell() } 59 | let title = faqItems[section].rows.first 60 | cell.update(title: title) 61 | return cell 62 | } 63 | } 64 | 65 | extension FAQView: ExpandableTableDelegate { 66 | func tableView(_: UITableView, viewForHeaderInSection section: Int) -> UIView? { 67 | let headerView = FAQHeaderView() 68 | headerView.update(title: faqItems[section].title) 69 | return headerView 70 | } 71 | 72 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 73 | tableView.deselectRow(at: indexPath, animated: false) 74 | guard isFirstRowAt(indexPath: indexPath) else { return } 75 | let sectionItem = faqItems[indexPath.section] 76 | let questionAnswer = QuestionAnswerModel( 77 | navigationTitle: sectionItem.rows.first, 78 | title: sectionItem.rows[indexPath.row], 79 | answerID: sectionItem.ids[indexPath.row] 80 | ) 81 | delegate?.didSelectQuestion(questionAnswer: questionAnswer) 82 | } 83 | 84 | func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat { 85 | return 56 86 | } 87 | 88 | func tableView(_: UITableView, heightForHeaderInSection section: Int) -> CGFloat { 89 | return faqItems[section].title != nil ? 80 : 0 90 | } 91 | } 92 | ``` 93 | 94 |

95 | 96 |

97 | -------------------------------------------------------------------------------- /gif/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucabelezal/ExpandableTableView/40bacd351b0213ee926ca685b5346b5b045701f2/gif/example.gif --------------------------------------------------------------------------------