├── preview.jpg ├── Example └── Example │ ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json │ ├── Info.plist │ ├── AppDelegate.swift │ ├── WaterfallHeaderFooterView.swift │ ├── WaterfallViewCell.swift │ ├── Base.lproj │ ├── Main.storyboard │ └── LaunchScreen.storyboard │ └── ViewController.swift ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Sources └── WaterfallLayout │ ├── Helpers.swift │ ├── UICollectionViewDelegateWaterfallLayout.swift │ └── WaterfallLayout.swift ├── Package.swift ├── LICENSE ├── .gitignore └── README.md /preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jinya/WaterfallLayout/HEAD/preview.jpg -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | 2 | import UIKit 3 | 4 | @main 5 | class AppDelegate: UIResponder, UIApplicationDelegate { 6 | 7 | var window: UIWindow? 8 | 9 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 10 | window = UIWindow() 11 | window?.rootViewController = ViewController() 12 | window?.makeKeyAndVisible() 13 | return true 14 | } 15 | 16 | } 17 | 18 | -------------------------------------------------------------------------------- /Sources/WaterfallLayout/Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WaterfallLayout 3 | // The MIT License (MIT) 4 | // 5 | // Copyright (c) 2018-2022 Jinya (https://github.com/Jinya) 6 | 7 | import UIKit 8 | 9 | extension CGSize { 10 | static func -(lhs: Self, rhs: Self) -> Self { 11 | return .init(width: lhs.width - rhs.width, height: lhs.height - rhs.height) 12 | } 13 | 14 | /// Return the size after content insets are applied. 15 | func applyingInset(_ inset: UIEdgeInsets) -> CGSize { 16 | return self - CGSize(width: inset.left + inset.right, 17 | height: inset.top + inset.bottom) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Example/Example/WaterfallHeaderFooterView.swift: -------------------------------------------------------------------------------- 1 | 2 | import UIKit 3 | 4 | class WaterfallHeaderFooterView: UICollectionReusableView { 5 | 6 | let titleLabel = UILabel() 7 | 8 | override init(frame: CGRect) { 9 | super.init(frame: frame) 10 | 11 | backgroundColor = .darkGray 12 | 13 | titleLabel.font = .preferredFont(forTextStyle: .largeTitle) 14 | titleLabel.textColor = .white 15 | titleLabel.textAlignment = .center 16 | 17 | addSubview(titleLabel) 18 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 19 | NSLayoutConstraint.activate([ 20 | titleLabel.topAnchor.constraint(equalTo: topAnchor), 21 | titleLabel.leftAnchor.constraint(equalTo: leftAnchor), 22 | titleLabel.rightAnchor.constraint(equalTo: rightAnchor), 23 | titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor) 24 | ]) 25 | } 26 | 27 | required init?(coder: NSCoder) { 28 | fatalError("init(coder:) has not been implemented") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "WaterfallLayout", 8 | platforms: [.iOS(.v11)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "WaterfallLayout", 13 | targets: ["WaterfallLayout"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 22 | .target( 23 | name: "WaterfallLayout", 24 | dependencies: []), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jinya 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Example/Example/WaterfallViewCell.swift: -------------------------------------------------------------------------------- 1 | 2 | import UIKit 3 | 4 | class WaterfallViewCell: UICollectionViewCell { 5 | let titleLabel = UILabel() 6 | 7 | override init(frame: CGRect) { 8 | super.init(frame: frame) 9 | 10 | titleLabel.font = .preferredFont(forTextStyle: .largeTitle) 11 | titleLabel.textColor = .white 12 | titleLabel.textAlignment = .center 13 | 14 | titleLabel.layer.shadowOffset = CGSize(width: 1, height: 1) 15 | titleLabel.layer.shadowOpacity = 0.8 16 | titleLabel.layer.shadowRadius = 2 17 | titleLabel.layer.shadowColor = UIColor.lightGray.cgColor 18 | 19 | contentView.addSubview(titleLabel) 20 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 21 | NSLayoutConstraint.activate([ 22 | titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor), 23 | titleLabel.leftAnchor.constraint(equalTo: contentView.leftAnchor), 24 | titleLabel.rightAnchor.constraint(equalTo: contentView.rightAnchor), 25 | titleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) 26 | ]) 27 | } 28 | 29 | required init?(coder: NSCoder) { fatalError() } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /Example/Example/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 | -------------------------------------------------------------------------------- /Example/Example/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 | -------------------------------------------------------------------------------- /Example/Example/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" : "2x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "83.5x83.5" 82 | }, 83 | { 84 | "idiom" : "ios-marketing", 85 | "scale" : "1x", 86 | "size" : "1024x1024" 87 | } 88 | ], 89 | "info" : { 90 | "author" : "xcode", 91 | "version" : 1 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/WaterfallLayout/UICollectionViewDelegateWaterfallLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WaterfallLayout 3 | // The MIT License (MIT) 4 | // 5 | // Copyright (c) 2018-2022 Jinya (https://github.com/Jinya) 6 | 7 | import UIKit 8 | 9 | @available(iOS 11.0, *) 10 | @objc public protocol UICollectionViewDelegateWaterfallLayout: UICollectionViewDelegate { 11 | 12 | // MARK: - Getting the Size of Items 13 | 14 | /// Asks the delegate for the size of the specified item’s cell. 15 | /// 16 | /// If you do not implement this method, the waterfall layout uses the values in its `itemSize` property to set the size of items instead. Your implementation of this method can return a fixed set of sizes or dynamically adjust the sizes based on the cell’s content. 17 | @objc optional func collectionView(_ collectionView: UICollectionView, 18 | layout collectionViewLayout: UICollectionViewLayout, 19 | sizeForItemAt indexPath: IndexPath) -> CGSize 20 | 21 | /// Asks the delegate for the number of columns in the specified section. 22 | /// 23 | /// If you do not implement this method, the waterfall layout uses the values in its `numberOfColumns` property to set the number of columns instead. 24 | @objc optional func collectionView(_ collectionView: UICollectionView, 25 | layout collectionViewLayout: UICollectionViewLayout, 26 | numberOfColumnsInSection section: Int) -> Int 27 | 28 | // MARK: - Getting the Section Spacing 29 | 30 | /// Asks the delegate for the margins to apply to content in the specified section. 31 | /// 32 | /// If you do not implement this method, the waterfall layout uses the value in its `sectionInset` property to set the margins instead. 33 | @objc optional func collectionView(_ collectionView: UICollectionView, 34 | layout collectionViewLayout: UICollectionViewLayout, 35 | insetForSectionAt section: Int) -> UIEdgeInsets 36 | 37 | /// Asks the delegate for the spacing between columns of a section. 38 | /// 39 | /// If you do not implement this method, the waterfall layout uses the value in its `minimumColumnSpacing` property to set the space between columns instead. 40 | @objc optional func collectionView(_ collectionView: UICollectionView, 41 | layout collectionViewLayout: UICollectionViewLayout, 42 | minimumColumnSpacingForSectionAt section: Int) -> CGFloat 43 | 44 | /// Asks the delegate for the size of the footer view in the specified section. 45 | /// 46 | /// If you do not implement this method, the waterfall layout uses the value in its `minimumInteritemSpacing` property to set the space between items instead. 47 | @objc optional func collectionView(_ collectionView: UICollectionView, 48 | layout collectionViewLayout: UICollectionViewLayout, 49 | minimumInteritemSpacingForSectionAt section: Int) -> CGFloat 50 | 51 | // MARK: - Getting the Header and Footer Sizes 52 | 53 | /// Asks the delegate for the size of the header view in the specified section. 54 | /// 55 | /// If you do not implement this method, the waterfall layout uses the value in its `headerReferenceSize` property to set the size of the header. 56 | @objc optional func collectionView(_ collectionView: UICollectionView, 57 | layout collectionViewLayout: UICollectionViewLayout, 58 | referenceSizeForHeaderInSection section: Int) -> CGSize 59 | 60 | /// Asks the delegate for the size of the footer view in the specified section. 61 | /// 62 | /// If you do not implement this method, the waterfall layout uses the value in its `footerReferenceSize` property to set the size of the footer. 63 | @objc optional func collectionView(_ collectionView: UICollectionView, 64 | layout collectionViewLayout: UICollectionViewLayout, 65 | referenceSizeForFooterInSection section: Int) -> CGSize 66 | } 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/macos,xcode,swift,swiftpm,swiftpackagemanager,git 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,xcode,swift,swiftpm,swiftpackagemanager,git 4 | 5 | ### Git ### 6 | # Created by git for backups. To disable backups in Git: 7 | # $ git config --global mergetool.keepBackup false 8 | *.orig 9 | 10 | # Created by git when using merge tools for conflicts 11 | *.BACKUP.* 12 | *.BASE.* 13 | *.LOCAL.* 14 | *.REMOTE.* 15 | *_BACKUP_*.txt 16 | *_BASE_*.txt 17 | *_LOCAL_*.txt 18 | *_REMOTE_*.txt 19 | 20 | ### macOS ### 21 | # General 22 | .DS_Store 23 | .AppleDouble 24 | .LSOverride 25 | 26 | # Icon must end with two \r 27 | Icon 28 | 29 | 30 | # Thumbnails 31 | ._* 32 | 33 | # Files that might appear in the root of a volume 34 | .DocumentRevisions-V100 35 | .fseventsd 36 | .Spotlight-V100 37 | .TemporaryItems 38 | .Trashes 39 | .VolumeIcon.icns 40 | .com.apple.timemachine.donotpresent 41 | 42 | # Directories potentially created on remote AFP share 43 | .AppleDB 44 | .AppleDesktop 45 | Network Trash Folder 46 | Temporary Items 47 | .apdisk 48 | 49 | ### Swift ### 50 | # Xcode 51 | # 52 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 53 | 54 | ## User settings 55 | xcuserdata/ 56 | 57 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 58 | *.xcscmblueprint 59 | *.xccheckout 60 | 61 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 62 | build/ 63 | DerivedData/ 64 | *.moved-aside 65 | *.pbxuser 66 | !default.pbxuser 67 | *.mode1v3 68 | !default.mode1v3 69 | *.mode2v3 70 | !default.mode2v3 71 | *.perspectivev3 72 | !default.perspectivev3 73 | 74 | ## Obj-C/Swift specific 75 | *.hmap 76 | 77 | ## App packaging 78 | *.ipa 79 | *.dSYM.zip 80 | *.dSYM 81 | 82 | ## Playgrounds 83 | timeline.xctimeline 84 | playground.xcworkspace 85 | 86 | # Swift Package Manager 87 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 88 | # Packages/ 89 | # Package.pins 90 | # Package.resolved 91 | # *.xcodeproj 92 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 93 | # hence it is not needed unless you have added a package configuration file to your project 94 | # .swiftpm 95 | 96 | .build/ 97 | 98 | # CocoaPods 99 | # We recommend against adding the Pods directory to your .gitignore. However 100 | # you should judge for yourself, the pros and cons are mentioned at: 101 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 102 | # Pods/ 103 | # Add this line if you want to avoid checking in source code from the Xcode workspace 104 | # *.xcworkspace 105 | 106 | # Carthage 107 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 108 | # Carthage/Checkouts 109 | 110 | Carthage/Build/ 111 | 112 | # Accio dependency management 113 | Dependencies/ 114 | .accio/ 115 | 116 | # fastlane 117 | # It is recommended to not store the screenshots in the git repo. 118 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 119 | # For more information about the recommended setup visit: 120 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 121 | 122 | fastlane/report.xml 123 | fastlane/Preview.html 124 | fastlane/screenshots/**/*.png 125 | fastlane/test_output 126 | 127 | # Code Injection 128 | # After new code Injection tools there's a generated folder /iOSInjectionProject 129 | # https://github.com/johnno1962/injectionforxcode 130 | 131 | iOSInjectionProject/ 132 | 133 | ### SwiftPackageManager ### 134 | Packages 135 | xcuserdata 136 | *.xcodeproj 137 | 138 | 139 | ### SwiftPM ### 140 | 141 | 142 | ### Xcode ### 143 | # Xcode 144 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 145 | 146 | 147 | 148 | 149 | ## Gcc Patch 150 | /*.gcno 151 | 152 | ### Xcode Patch ### 153 | *.xcodeproj/* 154 | !*.xcodeproj/project.pbxproj 155 | !*.xcodeproj/xcshareddata/ 156 | !*.xcworkspace/contents.xcworkspacedata 157 | **/xcshareddata/WorkspaceSettings.xcsettings 158 | 159 | # End of https://www.toptal.com/developers/gitignore/api/macos,xcode,swift,swiftpm,swiftpackagemanager,git 160 | .DS_Store 161 | -------------------------------------------------------------------------------- /Example/Example/ViewController.swift: -------------------------------------------------------------------------------- 1 | 2 | import UIKit 3 | import WaterfallLayout 4 | 5 | let cellReuseIdentifier = "cellReuseIdentifier" 6 | let headerReuseIdentifier = "headerReuseIdentifier" 7 | let footerReuseIdentifier = "footerReuseIdentifier" 8 | 9 | class ViewController: UIViewController { 10 | let waterfallView = UICollectionView(frame: .zero, collectionViewLayout: WaterfallLayout()) 11 | 12 | let colors: [UIColor] = [.red, .magenta, .brown, .blue, .purple, .blue, .cyan, .gray, .green, .yellow, .purple] 13 | 14 | lazy var cellSizes: [CGSize] = { 15 | let width = 500 16 | var sizes = [CGSize]() 17 | (0...100).forEach { _ in 18 | let height = [200, 300, 400, 500, 600, 700, 800, 900].randomElement()! 19 | sizes.append(.init(width: width, height: height)) 20 | } 21 | return sizes 22 | }() 23 | 24 | override func viewDidLoad() { 25 | super.viewDidLoad() 26 | 27 | waterfallView.register(WaterfallViewCell.self, forCellWithReuseIdentifier: cellReuseIdentifier) 28 | waterfallView.register(WaterfallHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: headerReuseIdentifier) 29 | waterfallView.register(WaterfallHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: footerReuseIdentifier) 30 | waterfallView.dataSource = self 31 | waterfallView.delegate = self 32 | 33 | view.addSubview(waterfallView) 34 | waterfallView.translatesAutoresizingMaskIntoConstraints = false 35 | NSLayoutConstraint.activate([ 36 | waterfallView.topAnchor.constraint(equalTo: view.topAnchor), 37 | waterfallView.leftAnchor.constraint(equalTo: view.leftAnchor), 38 | waterfallView.rightAnchor.constraint(equalTo: view.rightAnchor), 39 | waterfallView.bottomAnchor.constraint(equalTo: view.bottomAnchor) 40 | ]) 41 | } 42 | } 43 | 44 | extension ViewController: UICollectionViewDataSource { 45 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 46 | return cellSizes.count 47 | } 48 | 49 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 50 | guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellReuseIdentifier, for: indexPath) as? WaterfallViewCell else { 51 | fatalError() 52 | } 53 | cell.titleLabel.text = "cell \(indexPath.item)" 54 | cell.contentView.backgroundColor = colors.randomElement()! 55 | return cell 56 | } 57 | 58 | func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { 59 | switch kind { 60 | case UICollectionView.elementKindSectionHeader: 61 | guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: headerReuseIdentifier, for: indexPath) as? WaterfallHeaderFooterView else { 62 | fatalError() 63 | } 64 | header.titleLabel.text = "Header" 65 | return header 66 | case UICollectionView.elementKindSectionFooter: 67 | guard let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: footerReuseIdentifier, for: indexPath) as? WaterfallHeaderFooterView else { 68 | fatalError() 69 | } 70 | footer.titleLabel.text = "footer" 71 | return footer 72 | default: 73 | fatalError() 74 | } 75 | } 76 | } 77 | 78 | extension ViewController: UICollectionViewDelegateWaterfallLayout { 79 | func numberOfSections(in collectionView: UICollectionView) -> Int { 80 | return 1 81 | } 82 | 83 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, numberOfColumnsInSection section: Int) -> Int { 84 | return traitCollection.horizontalSizeClass == .compact ? 2 : 4 85 | } 86 | 87 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 88 | return cellSizes[indexPath.item] 89 | } 90 | 91 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { 92 | return .init(top: 10, left: 10, bottom: 10, right: 10) 93 | } 94 | 95 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumColumnSpacingForSectionAt section: Int) -> CGFloat { 96 | return 10 97 | } 98 | 99 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { 100 | return 10 101 | } 102 | 103 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { 104 | return .init(width: collectionView.bounds.width, height: 80) 105 | } 106 | 107 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { 108 | return .init(width: collectionView.bounds.width, height: 80) 109 | } 110 | } 111 | 112 | extension ViewController { 113 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 114 | print("Did select cell at \(indexPath.description)") 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WaterfallLayout 2 | 3 | `WaterfallLayout` is a `UICollectionViewLayout` subclass for vertically laying out views like a waterfall, just like Pinterest app. 4 | 5 | ## Preview of the Example App 6 | 7 |
8 | Preview 9 |
10 | 11 | ## Requirements 12 | 13 | Deployment target iOS 11.0+ 14 | 15 | ## Installation 16 | 17 | ### Swift Package Manager 18 | 19 | 1. Xcode > File > Swift Packages > Add Package Dependency 20 | 2. Add `https://github.com/Jinya/WaterfallLayout.git` 21 | 3. Select "Up to Next Minor" from "0.2.0" 22 | 23 | ## Usage 24 | 25 | Once you've integrated the `WaterfallLayout` into your project, using it with a collection view is easy. 26 | 27 | ### Importing WaterfallLayout 28 | 29 | At the top of the file where you'd like to use `WaterfallLayout` (likely `UIViewController` subclass). 30 | 31 | ```swift 32 | import WaterfallLayout 33 | ``` 34 | 35 | ### Setting up the collection view 36 | 37 | Create your `UICollectionView` instance, passing in a `WaterfallLayout` instance for the layout parameter. 38 | 39 | ```swift 40 | let layout = WaterfallLayout() 41 | let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) 42 | ``` 43 | 44 | Add `collectionView` to its superview, then properly constrain it using Auto Layout or manually set its `frame` property. 45 | 46 | ```swift 47 | view.addSubview(collectionView) 48 | 49 | collectionView.translatesAutoresizingMaskIntoConstraints = false 50 | 51 | NSLayoutConstraint.activate([ 52 | collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 53 | collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 54 | collectionView.topAnchor.constraint(equalTo: view.topAnchor), 55 | collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 56 | ]) 57 | ``` 58 | 59 | ### Registering cells and supplementary views 60 | 61 | Register your cell and reusable view types with your collection view. 62 | 63 | ```swift 64 | collectionView.register(MyCustomCell.self, forCellWithReuseIdentifier: "MyCustomCellReuseIdentifier") 65 | 66 | // Only necessary if you want section headers 67 | collectionView.register(MyCustomHeader.self, forSupplementaryViewOfKind: UICollectionView.SupplementaryViewKind.sectionHeader, withReuseIdentifier: "MyCustomHeaderReuseIdentifier") 68 | 69 | // Only necessary if you want section footers 70 | collectionView.register(MyCustomFooter.self, forSupplementaryViewOfKind: UICollectionView.SupplementaryViewKind.sectionFooter, withReuseIdentifier: "MyCustomFooterReuseIdentifier") 71 | ``` 72 | 73 | ### Setting the data source 74 | 75 | Now that you've registered your view types with your collection view, it's time to wire up the data source. Like with any collection view integration, your data source needs to conform to `UICollectionViewDataSource`. If the same object that owns your collection view is also your data source, you can simply do this: 76 | 77 | ```swift 78 | collectionView.dataSource = self 79 | ``` 80 | 81 | ### Configuring the delegate 82 | 83 | Lastly, it's time to configure the layout to suit your needs. Like with `UICollectionViewFlowLayout` and `UICollectionViewDelegateFlowLayout`, `WaterfallLayout` configured its layout through its `UICollectionViewDelegateWaterfallLayout`. 84 | 85 | To start configuring `WaterfallLayout`, set your collection view's `delegate` property to an object conforming to `UICollectionViewDelegateWaterfallLayout`. If the same object that owns your collection view is also your delegate, you can simply do this: 86 | 87 | ```swift 88 | collectionView.delegate = self 89 | ``` 90 | 91 | Here's an example delegate implementation: 92 | 93 | ```swift 94 | extension ViewController: UICollectionViewDelegateWaterfallLayout { 95 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, numberOfColumnsInSection section: Int) -> Int { 96 | // You can dynamically configure the number of columns in a section here, e.g., depending on the horizontal size of the collection view. 97 | return traitCollection.horizontalSizeClass == .compact ? 2 : 4 98 | } 99 | 100 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 101 | // Here to configure size for every cell. 102 | } 103 | 104 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { 105 | return .init(top: 10, left: 10, bottom: 10, right: 10) 106 | } 107 | 108 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumColumnSpacingForSectionAt section: Int) -> CGFloat { 109 | return 10 110 | } 111 | 112 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { 113 | return 10 114 | } 115 | 116 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { 117 | return .init(width: collectionView.bounds.width, height: 80) 118 | } 119 | 120 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { 121 | return .init(width: collectionView.bounds.width, height: 80) 122 | } 123 | } 124 | ``` 125 | 126 | If you've followed the steps above, you should have a working `UICollectionView` using `WaterfallLayout`! If you'd like to work with a pre-made example, check out the included example project. 127 | 128 | ## MIT License 129 | 130 | WaterfallLayout released under the MIT license. See LICENSE for details. 131 | 132 | ## Acknowledgement 133 | 134 | `WaterfallLayout` was heavily inspired by [`CHTCollectionViewWaterfallLayout`](https://github.com/chiahsien/CHTCollectionViewWaterfallLayout.git). 135 | -------------------------------------------------------------------------------- /Sources/WaterfallLayout/WaterfallLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WaterfallLayout 3 | // The MIT License (MIT) 4 | // 5 | // Copyright (c) 2018-2022 Jinya (https://github.com/Jinya) 6 | 7 | import UIKit 8 | 9 | extension WaterfallLayout { 10 | /// Constants that describe the reference point of the section insets. 11 | public enum SectionInsetReference: Int { 12 | /// Section insets are defined in relation to the collection view's content inset. 13 | case fromContentInset = 0 14 | 15 | /// Section insets are defined in relation to the safe area of the layout. 16 | case fromSafeArea = 1 17 | 18 | /// Section insets are defined in relation to the margins of the layout. 19 | case fromLayoutMargins = 2 20 | } 21 | } 22 | 23 | /// A layout object that organizes items into a waterfall with optional header and footer views for each section. 24 | @available(iOS 11.0, *) 25 | public class WaterfallLayout: UICollectionViewLayout { 26 | /// The default size to use for cells. 27 | /// 28 | /// If the delegate does not implement the collectionView(_:layout:sizeForItemAt:) method, the waterfall layout uses the value in this property to set the size of each cell. This results in cells that all have the same size. 29 | /// 30 | /// The default size value is (50.0, 50.0). 31 | public var itemSize: CGSize = .init(width: 50, height: 50) { 32 | didSet { invalidateLayout() } 33 | } 34 | 35 | public var numberOfColumns: Int = 1 { 36 | didSet { 37 | invalidateLayout() 38 | } 39 | } 40 | 41 | /// The minimum spacing to use between columns of items in the waterfall. 42 | public var minimumColumnSpacing: CGFloat = 0 { 43 | didSet { invalidateLayout() } 44 | } 45 | 46 | /// The minimum spacing to use between items in the same row. 47 | public var minimumInteritemSpacing: CGFloat = 0 { 48 | didSet { invalidateLayout() } 49 | } 50 | 51 | /// The margins used to lay out content in a section. 52 | public var sectionInset: UIEdgeInsets = .zero { 53 | didSet { invalidateLayout() } 54 | } 55 | 56 | /// The boundary that section insets are defined in relation to. 57 | /// 58 | /// The default value of this property is `WaterfallLayout.SectionInsetReference.fromContentInset`. 59 | /// 60 | /// The minimum value of this property is always the collection view's contentInset. For example, if the value of this property is `WaterfallLayout.SectionInsetReference.fromSafeArea`, but the adjusted content inset is greater than the combination of the safe area and section insets, then the section's content is aligned with the content inset instead. 61 | public var sectionInsetReference: SectionInsetReference = .fromContentInset { 62 | didSet { invalidateLayout() } 63 | } 64 | 65 | public var headerReferenceSize: CGSize = .zero { 66 | didSet { invalidateLayout() } 67 | } 68 | 69 | public var footerReferenceSize: CGSize = .zero { 70 | didSet { invalidateLayout() } 71 | } 72 | 73 | public var delegate: UICollectionViewDelegateWaterfallLayout? { 74 | get { 75 | return collectionView!.delegate as? UICollectionViewDelegateWaterfallLayout 76 | } 77 | } 78 | 79 | // ╔═════════════════════╦═════════╦═════════╗ 80 | // ║ `columnHeight` ║ column 0║ column 1║ 81 | // ╠═════════════════════╬═════════╬═════════╣ 82 | // ║ section 0 ║ 200 ║ 220 ║ 83 | // ╠═════════════════════╬═════════╬═════════╣ 84 | // ║ section 1 ║ 550 ║ 530 ║ 85 | // ╠═════════════════════╬═════════╬═════════╣ 86 | // ║ section 2 ║ 800 ║ 820 ║ 87 | // ╚═════════════════════╩═════════╩═════════╝ 88 | 89 | /// Array of arrays. Each array stores all waterfall column heights for each section. 90 | private var columnHeights: [[CGFloat]] = [] 91 | 92 | /// Array of arrays. Each array stores item attributes for each section. 93 | private var attributesForSectionItems: [[UICollectionViewLayoutAttributes]] = [] 94 | 95 | /// LayoutAttributes for all elements in the collection view, including cells, supplementary views, and decoration views. 96 | private var attributesForAllElements: [UICollectionViewLayoutAttributes] = [] 97 | 98 | private var attributesForHeaders: [Int: UICollectionViewLayoutAttributes] = [:] 99 | private var attributesForFooters: [Int: UICollectionViewLayoutAttributes] = [:] 100 | 101 | /// Array to store union rectangles. 102 | private var unionRects: [CGRect] = [] 103 | private let unionSize = 20 104 | 105 | public override func invalidateLayout() { 106 | super.invalidateLayout() 107 | } 108 | 109 | public override func prepare() { 110 | super.prepare() 111 | 112 | let numberOfSections = collectionView!.numberOfSections 113 | guard numberOfSections > 0 else { 114 | return 115 | } 116 | 117 | attributesForHeaders = [:] 118 | attributesForFooters = [:] 119 | unionRects = [] 120 | attributesForAllElements = [] 121 | attributesForSectionItems = .init(repeating: [], count: numberOfSections) 122 | columnHeights = .init(repeating: [], count: numberOfSections) 123 | 124 | var top: CGFloat = 0 125 | var attributes = UICollectionViewLayoutAttributes() 126 | 127 | for section in 0.. 0 { 139 | attributes = UICollectionViewLayoutAttributes( 140 | forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, 141 | with: IndexPath(row: 0, section: section) 142 | ) 143 | attributes.frame = CGRect(x: 0, y: top, 144 | width: headerSize.width, 145 | height: headerSize.height) 146 | 147 | attributesForHeaders[section] = attributes 148 | attributesForAllElements.append(attributes) 149 | 150 | top = attributes.frame.maxY 151 | } 152 | 153 | top += sectionInset.top 154 | columnHeights[section] = [CGFloat](repeating: top, count: numberOfColumns) 155 | 156 | // 3. Cells 157 | let numberOfItems = collectionView!.numberOfItems(inSection: section) 158 | 159 | // Every item will be put into the shortest column of current section. 160 | for item in 0.. 0 { 185 | attributes = UICollectionViewLayoutAttributes( 186 | forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, 187 | with: IndexPath(item: 0, section: section) 188 | ) 189 | attributes.frame = CGRect(x: 0, y: top, 190 | width: footerSize.width, 191 | height: footerSize.height) 192 | 193 | attributesForFooters[section] = attributes 194 | attributesForAllElements.append(attributes) 195 | 196 | top = attributes.frame.maxY 197 | } 198 | 199 | columnHeights[section] = [CGFloat](repeating: top, count: numberOfColumns) 200 | } 201 | 202 | // Cache rects 203 | let count = attributesForAllElements.count 204 | var i = 0 205 | while i < count { 206 | let rect1 = attributesForAllElements[i].frame 207 | i = min(i + unionSize, count) - 1 208 | let rect2 = attributesForAllElements[i].frame 209 | unionRects.append(rect1.union(rect2)) 210 | i += 1 211 | } 212 | } 213 | 214 | public override var collectionViewContentSize: CGSize { 215 | guard collectionView!.numberOfSections > 0, 216 | let collectionViewContentHeight = columnHeights.last?.first else { 217 | return .zero 218 | } 219 | return .init(width: collectionViewEffectiveContentSize.width, height: collectionViewContentHeight) 220 | } 221 | 222 | public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { 223 | if indexPath.section >= attributesForSectionItems.count { 224 | return nil 225 | } 226 | let list = attributesForSectionItems[indexPath.section] 227 | if indexPath.item >= list.count { 228 | return nil 229 | } 230 | return list[indexPath.item] 231 | } 232 | 233 | public override func layoutAttributesForSupplementaryView( 234 | ofKind elementKind: String, 235 | at indexPath: IndexPath 236 | ) -> UICollectionViewLayoutAttributes { 237 | var attribute: UICollectionViewLayoutAttributes? 238 | if elementKind == UICollectionView.elementKindSectionHeader { 239 | attribute = attributesForHeaders[indexPath.section] 240 | } else if elementKind == UICollectionView.elementKindSectionFooter { 241 | attribute = attributesForFooters[indexPath.section] 242 | } 243 | return attribute ?? UICollectionViewLayoutAttributes() 244 | } 245 | 246 | public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { 247 | var begin = 0, end = unionRects.count 248 | 249 | if let i = unionRects.firstIndex(where: { rect.intersects($0) }) { 250 | begin = i * unionSize 251 | } 252 | if let i = unionRects.lastIndex(where: { rect.intersects($0) }) { 253 | end = min((i + 1) * unionSize, attributesForAllElements.count) 254 | } 255 | return attributesForAllElements[begin.. Bool { 260 | return newBounds.width != collectionView!.bounds.width 261 | } 262 | 263 | /// Find the column index for the item at specifying indexPath. 264 | private func columnIndex(forItemAt indexPath: IndexPath) -> Int { 265 | return shortestColumnIndex(inSection: indexPath.section) 266 | } 267 | 268 | /// Find the shortest column in the specifying section. 269 | private func shortestColumnIndex(inSection section: Int) -> Int { 270 | return columnHeights[section].enumerated() 271 | .min(by: { $0.element < $1.element })? 272 | .offset ?? 0 273 | } 274 | 275 | /// Find the longest column in the specifying section. 276 | private func longestColumnIndex(inSection section: Int) -> Int { 277 | return columnHeights[section].enumerated() 278 | .max(by: { $0.element < $1.element })? 279 | .offset ?? 0 280 | } 281 | } 282 | 283 | extension WaterfallLayout { 284 | private var collectionViewEffectiveContentSize: CGSize { 285 | let inset: UIEdgeInsets 286 | switch sectionInsetReference { 287 | case .fromContentInset: 288 | inset = collectionView!.contentInset 289 | case .fromSafeArea: 290 | inset = collectionView!.safeAreaInsets 291 | case .fromLayoutMargins: 292 | inset = collectionView!.layoutMargins 293 | } 294 | return collectionView!.bounds.size.applyingInset(inset) 295 | } 296 | 297 | private func effectiveContentWidth(forSection section: Int) -> CGFloat { 298 | let sectionInset = inset(forSection: section) 299 | return collectionViewEffectiveContentSize.width - sectionInset.left - sectionInset.right 300 | } 301 | 302 | private func effectiveItemWidth(inSection section: Int) -> CGFloat { 303 | let numberOfColumns = numberOfColumns(inSection: section) 304 | let columnSpacing = columnSpacing(forSection: section) 305 | let sectionContentWidth = effectiveContentWidth(forSection: section) 306 | let width = (sectionContentWidth - (columnSpacing * CGFloat(numberOfColumns - 1))) / CGFloat(numberOfColumns) 307 | assert(width >= 0, "Item's width should be negative value.") 308 | return width 309 | } 310 | 311 | private func itemSize(at indexPath: IndexPath) -> CGSize { 312 | let referenceItemSize = delegate?.collectionView?(collectionView!, layout: self, sizeForItemAt: indexPath) ?? itemSize 313 | assert(referenceItemSize.width.isNormal && referenceItemSize.height.isNormal, "Item size values must be normal values(not zero, subnormal, infinity, or NaN).") 314 | return referenceItemSize 315 | } 316 | 317 | private func numberOfColumns(inSection section: Int) -> Int { 318 | let numberOfColumns = delegate?.collectionView?(collectionView!, layout: self, numberOfColumnsInSection: section) ?? numberOfColumns 319 | assert(numberOfColumns > 0, "The number of columns must be greater than zero.") 320 | return numberOfColumns 321 | } 322 | 323 | private func inset(forSection section: Int) -> UIEdgeInsets { 324 | return delegate?.collectionView?(collectionView!, layout: self, insetForSectionAt: section) ?? sectionInset 325 | } 326 | 327 | private func columnSpacing(forSection section: Int) -> CGFloat { 328 | return delegate?.collectionView?(collectionView!, layout: self, minimumColumnSpacingForSectionAt: section) ?? minimumColumnSpacing 329 | } 330 | 331 | private func interitemSpacing(forSection section: Int) -> CGFloat { 332 | return delegate?.collectionView?(collectionView!, layout: self, minimumInteritemSpacingForSectionAt: section) ?? minimumInteritemSpacing 333 | } 334 | 335 | private func headerReferenceSize(inSection section: Int) -> CGSize { 336 | return delegate?.collectionView?(collectionView!, layout: self, referenceSizeForHeaderInSection: section) ?? headerReferenceSize 337 | } 338 | 339 | private func footerReferenceSize(inSection section: Int) -> CGSize { 340 | return delegate?.collectionView?(collectionView!, layout: self, referenceSizeForFooterInSection: section) ?? footerReferenceSize 341 | } 342 | } 343 | --------------------------------------------------------------------------------