├── 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 |

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 |
--------------------------------------------------------------------------------