├── PinterestCompositionalLayout
├── App
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── Info.plist
│ ├── AppDelegate.swift
│ ├── Base.lproj
│ │ └── LaunchScreen.storyboard
│ └── SceneDelegate.swift
├── Pinterest
│ ├── Models
│ │ ├── Ratioable.swift
│ │ ├── Config.swift
│ │ └── PictureModel.swift
│ ├── Views
│ │ ├── HeaderView.swift
│ │ └── PictureCell.swift
│ ├── NetworkLayer
│ │ ├── ImagesEndpoint.swift
│ │ └── ImagesNetworkService.swift
│ ├── PinterestViewModel.swift
│ ├── CustomCompositionalLayout.swift
│ ├── PinterestLayoutSection.swift
│ └── PinterestViewController.swift
└── BlurHashDecode
│ └── BlurHashDecode.swift
├── PinterestCompositionalLayout.xcodeproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
├── xcuserdata
│ └── v.chistiakov.xcuserdatad
│ │ └── xcschemes
│ │ └── xcschememanagement.plist
└── project.pbxproj
├── README.md
└── .gitignore
/PinterestCompositionalLayout/App/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/App/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 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/Pinterest/Models/Ratioable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Ratioable.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 05.02.2023.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol Ratioable {
11 | var ratio: CGFloat { get }
12 | }
13 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/App/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | }
8 | ],
9 | "info" : {
10 | "author" : "xcode",
11 | "version" : 1
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/Pinterest/Models/Config.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Config.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 05.02.2023.
6 | //
7 |
8 | import Foundation
9 |
10 | let accessKey = "tswvohI9oyY9BT3dxrFrdKavGSrh7HN-QjlZ-avMctQ"
11 | let secretKey = "Obkqp6GqiGBKGYIA_YexFIcItrJmTyJzWI63H5WNnB4"
12 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "snapkit",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/SnapKit/SnapKit.git",
7 | "state" : {
8 | "branch" : "develop",
9 | "revision" : "58320fe80522414bf3a7e24c88123581dc586752"
10 | }
11 | }
12 | ],
13 | "version" : 2
14 | }
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CoolDiscover
2 |
3 | The most exciting first screen in apps
4 |
5 |
6 | 
7 |
8 | Detailed explanation you can find via link below:
9 |
10 | Implementing custom UICollectionViewCompositionalLayout with Pinterest Section
11 |
12 | https://hackernoon.com/implementing-uicollectionview-compositional-layout-with-pinterest-section
13 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/App/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | UIApplicationSceneManifest
6 |
7 | UIApplicationSupportsMultipleScenes
8 |
9 | UISceneConfigurations
10 |
11 | UIWindowSceneSessionRoleApplication
12 |
13 |
14 | UISceneConfigurationName
15 | Default Configuration
16 | UISceneDelegateClassName
17 | $(PRODUCT_MODULE_NAME).SceneDelegate
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/Pinterest/Models/PictureModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PictureModel.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 01.02.2023.
6 | //
7 |
8 | import Foundation
9 |
10 | struct PictureModel: Hashable, Decodable {
11 |
12 | // MARK: - Urls
13 | struct Urls: Hashable, Decodable {
14 | let raw, full, regular, small, thumb: String
15 | }
16 |
17 | let description: String?
18 | let urls: Urls
19 | let width: CGFloat
20 | let height: CGFloat
21 | let blurHash: String
22 |
23 | var blurHashSize: CGSize {
24 | .init(width: width/100, height: height/100)
25 | }
26 | }
27 |
28 | extension PictureModel: Ratioable {
29 | var ratio: CGFloat {
30 | width / height
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/Pinterest/Views/HeaderView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HeaderView.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 11.02.2023.
6 | //
7 |
8 | import UIKit
9 |
10 | final class HeaderView: UICollectionReusableView {
11 |
12 | let titleLabel: UILabel = {
13 | let label = UILabel()
14 | label.font = UIFont.boldSystemFont(ofSize: 16)
15 | label.textColor = .black
16 | return label
17 | }()
18 |
19 | override init(frame: CGRect) {
20 | super.init(frame: frame)
21 | addSubview(titleLabel)
22 | titleLabel.snp.makeConstraints { make in
23 | make.edges.equalToSuperview()
24 | }
25 | }
26 |
27 | required init?(coder: NSCoder) {
28 | fatalError("init(coder:) has not been implemented")
29 | }
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/Pinterest/NetworkLayer/ImagesEndpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ImagesEndpoint.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 05.02.2023.
6 | //
7 |
8 | import Foundation
9 | import EasyNetwork
10 |
11 | enum ImagesEndpoint {
12 | case images(page: Int)
13 | }
14 |
15 | extension ImagesEndpoint: GetEndpoint {
16 |
17 | var header: Header? {
18 | ["Authorization": "Client-ID \(accessKey)"]
19 | }
20 |
21 | var host: String {
22 | "api.unsplash.com"
23 | }
24 |
25 | var path: String {
26 | switch self {
27 | case .images:
28 | return "/photos"
29 | }
30 | }
31 |
32 | var params: [URLQueryItem]? {
33 | switch self {
34 | case .images(let page):
35 | return [
36 | .init(name: "page", value: "\(page)"),
37 | .init(name: "per_page", value: "30")
38 | ]
39 | }
40 | }
41 |
42 | }
43 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/Pinterest/NetworkLayer/ImagesNetworkService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ImagesNetworkService.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 05.02.2023.
6 | //
7 |
8 | import Foundation
9 | import EasyNetwork
10 | import Combine
11 |
12 | protocol ImagesNetworkService {
13 | func getImages(page: Int) -> AnyPublisher<[PictureModel], RequestError>
14 | func getImage(urlString: String) -> AnyPublisher
15 | }
16 |
17 | final class ImagesNetworkServiceImpl: EasyNetworkClient, ImagesNetworkService {
18 | func getImages(page: Int) -> AnyPublisher<[PictureModel], RequestError> {
19 | sendRequest(
20 | endpoint: ImagesEndpoint.images(page: page),
21 | responseModelType: [PictureModel].self
22 | )
23 | }
24 |
25 | func getImage(urlString: String) -> AnyPublisher {
26 | guard let url = URL(string: urlString) else {
27 | return Fail(error: RequestError.urlMalformed)
28 | .eraseToAnyPublisher()
29 | }
30 | let request = URLRequest(url: url)
31 | return URLSession.shared.dataTaskPublisher(for: request)
32 | .map { $0.data }
33 | .mapError { _ in .unknown("Image can't load")}
34 | .eraseToAnyPublisher()
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/Pinterest/Views/PictureCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PictureCell.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 02.02.2023.
6 | //
7 |
8 | import UIKit
9 |
10 | final class PictureCell: UICollectionViewCell {
11 |
12 | lazy var imageView: UIImageView = {
13 | let imageView = UIImageView()
14 | imageView.contentMode = .scaleAspectFill
15 | return imageView
16 | }()
17 |
18 | lazy var titleLabel: UILabel = {
19 | let titleLabel = UILabel()
20 | titleLabel.textAlignment = .center
21 | return titleLabel
22 | }()
23 |
24 | override init(frame: CGRect) {
25 | super.init(frame: frame)
26 | contentView.backgroundColor = .lightGray
27 | contentView.layer.cornerRadius = 10
28 | contentView.layer.masksToBounds = true
29 | contentView.addSubview(imageView)
30 | contentView.addSubview(titleLabel)
31 |
32 | imageView.snp.makeConstraints { make in
33 | make.edges.equalToSuperview()
34 | }
35 |
36 | titleLabel.snp.makeConstraints { make in
37 | make.bottom.left.right.equalToSuperview().inset(5)
38 | }
39 | }
40 |
41 | required init?(coder: NSCoder) {
42 | fatalError("init(coder:) has not been implemented")
43 | }
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/App/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 01.02.2023.
6 | //
7 |
8 | import UIKit
9 |
10 | @main
11 | class AppDelegate: UIResponder, UIApplicationDelegate {
12 |
13 |
14 |
15 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
16 | // Override point for customization after application launch.
17 | return true
18 | }
19 |
20 | // MARK: UISceneSession Lifecycle
21 |
22 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
23 | // Called when a new scene session is being created.
24 | // Use this method to select a configuration to create the new scene with.
25 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
26 | }
27 |
28 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {
29 | // Called when the user discards a scene session.
30 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
31 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
32 | }
33 |
34 |
35 | }
36 |
37 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout.xcodeproj/xcuserdata/v.chistiakov.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | PinterestCompositionalLayout.xcscheme_^#shared#^_
8 |
9 | orderHint
10 | 0
11 |
12 | SnapKitPlayground (Playground) 1.xcscheme
13 |
14 | isShown
15 |
16 | orderHint
17 | 3
18 |
19 | SnapKitPlayground (Playground) 2.xcscheme
20 |
21 | isShown
22 |
23 | orderHint
24 | 4
25 |
26 | SnapKitPlayground (Playground) 3.xcscheme
27 |
28 | isShown
29 |
30 | orderHint
31 | 2
32 |
33 | SnapKitPlayground (Playground) 4.xcscheme
34 |
35 | isShown
36 |
37 | orderHint
38 | 5
39 |
40 | SnapKitPlayground (Playground) 5.xcscheme
41 |
42 | isShown
43 |
44 | orderHint
45 | 6
46 |
47 | SnapKitPlayground (Playground).xcscheme
48 |
49 | isShown
50 |
51 | orderHint
52 | 0
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/App/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/App/SceneDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SceneDelegate.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 01.02.2023.
6 | //
7 |
8 | import UIKit
9 |
10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate {
11 |
12 | var window: UIWindow?
13 |
14 |
15 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
16 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
17 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
18 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
19 | guard let scene = scene as? UIWindowScene else { return }
20 | self.window = UIWindow(windowScene: scene)
21 |
22 | let viewModel = PinterestViewModelImpl(imagesNetworkService: ImagesNetworkServiceImpl())
23 | let vc = PinterestViewController(viewModel: viewModel)
24 | let navigationController = UINavigationController(rootViewController: vc)
25 | window?.rootViewController = navigationController
26 | window?.makeKeyAndVisible()
27 | }
28 |
29 | func sceneDidDisconnect(_ scene: UIScene) {
30 | // Called as the scene is being released by the system.
31 | // This occurs shortly after the scene enters the background, or when its session is discarded.
32 | // Release any resources associated with this scene that can be re-created the next time the scene connects.
33 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
34 | }
35 |
36 | func sceneDidBecomeActive(_ scene: UIScene) {
37 | // Called when the scene has moved from an inactive state to an active state.
38 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
39 | }
40 |
41 | func sceneWillResignActive(_ scene: UIScene) {
42 | // Called when the scene will move from an active state to an inactive state.
43 | // This may occur due to temporary interruptions (ex. an incoming phone call).
44 | }
45 |
46 | func sceneWillEnterForeground(_ scene: UIScene) {
47 | // Called as the scene transitions from the background to the foreground.
48 | // Use this method to undo the changes made on entering the background.
49 | }
50 |
51 | func sceneDidEnterBackground(_ scene: UIScene) {
52 | // Called as the scene transitions from the foreground to the background.
53 | // Use this method to save data, release shared resources, and store enough scene-specific state information
54 | // to restore the scene back to its current state.
55 | }
56 |
57 |
58 | }
59 |
60 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/Pinterest/PinterestViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PinterestViewModel.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 05.02.2023.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 | import EasyNetwork
11 |
12 | protocol PinterestViewModel: AnyObject {
13 | var dataSource: DataSource! { get set }
14 | var isRefreshing: Bool { get }
15 |
16 | func refresh() -> AnyPublisher<[Ratioable], Never>
17 | func loadImages(animatingDifferences: Bool) -> AnyPublisher<[Ratioable], Never>
18 | func loadImage(for index: Int, inSection section: Section) -> AnyPublisher
19 | }
20 |
21 | final class PinterestViewModelImpl: PinterestViewModel {
22 |
23 | var dataSource: DataSource!
24 |
25 | private var snapshot = DataSourceSnapshot()
26 | private(set) var isRefreshing: Bool = false
27 |
28 | private let imagesNetworkService: ImagesNetworkService
29 | private var cancellables = Set()
30 |
31 | init(imagesNetworkService: ImagesNetworkService) {
32 | self.imagesNetworkService = imagesNetworkService
33 | }
34 |
35 | func loadImages(animatingDifferences: Bool = false) -> AnyPublisher<[Ratioable], Never> {
36 | imagesNetworkService.getImages(page: (1...10).randomElement() ?? 1)
37 | .receive(on: DispatchQueue.main)
38 | .handleEvents(receiveOutput: { [weak self] pictures in
39 | self?.configureDataSource(pictures: pictures, animatingDifferences: false)
40 | }, receiveCompletion: { completion in
41 | switch completion {
42 | case .finished:
43 | break
44 | case .failure(let error):
45 | print("error \(error.debugDescription)")
46 | }
47 | })
48 | // .map { $0[($0.count/2)+1...$0.count-1] }
49 | .map { $0.map { $0 as Ratioable }}
50 | .replaceError(with: [])
51 | .eraseToAnyPublisher()
52 | }
53 |
54 | func loadImage(for index: Int, inSection section: Section) -> AnyPublisher {
55 | imagesNetworkService.getImage(urlString: snapshot.itemIdentifiers(inSection: section)[index].urls.small)
56 | .receive(on: DispatchQueue.main)
57 | .eraseToAnyPublisher()
58 | }
59 |
60 | func refresh() -> AnyPublisher<[Ratioable], Never> {
61 | isRefreshing = true
62 | return loadImages(animatingDifferences: true)
63 | }
64 |
65 | //MARK: - Private methods
66 |
67 | private func configureDataSource(pictures: [PictureModel], animatingDifferences: Bool) {
68 | snapshot.deleteAllItems()
69 | snapshot.appendSections(Section.allCases)
70 |
71 | snapshot.appendItems(pictures[20...29].map { $0 }, toSection: .carousel)
72 | snapshot.appendItems(pictures[10...19].map { $0 }, toSection: .widget)
73 | snapshot.appendItems(pictures[0...9].map { $0 }, toSection: .pinterest)
74 |
75 | dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
76 | isRefreshing = false
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/Pinterest/CustomCompositionalLayout.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomCompositionalLayout.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 01.02.2023.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 |
11 | final class CustomCompositionalLayout {
12 |
13 | static func layout(ratios: [Ratioable], contentWidth: CGFloat) -> UICollectionViewCompositionalLayout {
14 | .init { sectionIndex, enviroment in
15 | guard let section = Section(rawValue: sectionIndex)
16 | else { return nil }
17 | switch section {
18 | case .carousel :
19 | return carouselBannerSection()
20 | case .widget :
21 | return widgetBannerSection()
22 | case .pinterest:
23 | return pinterestSection(ratios: ratios, contentWidth: contentWidth)
24 | }
25 | }
26 | }
27 |
28 | private static func carouselBannerSection() -> NSCollectionLayoutSection {
29 | let itemSize = NSCollectionLayoutSize(
30 | widthDimension: .fractionalWidth(1),
31 | heightDimension: .fractionalHeight(1)
32 | )
33 | let item = NSCollectionLayoutItem(layoutSize: itemSize)
34 |
35 | let groupSize = NSCollectionLayoutSize(
36 | widthDimension: .fractionalWidth(1),
37 | heightDimension: .fractionalWidth(1)
38 | )
39 | let group = NSCollectionLayoutGroup.horizontal(
40 | layoutSize: groupSize,
41 | subitems: [item]
42 | )
43 | let section = NSCollectionLayoutSection(group: group)
44 | section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
45 | section.visibleItemsInvalidationHandler = { (items, offset, environment) in
46 | items.forEach { item in
47 | let distanceFromCenter = abs((item.frame.midX - offset.x) - environment.container.contentSize.width / 2.0)
48 | let minScale: CGFloat = 0.8
49 | let maxScale: CGFloat = 1.0 - distanceFromCenter / environment.container.contentSize.width
50 | let scale = max(maxScale, minScale)
51 | item.transform = CGAffineTransform(scaleX: scale, y: scale)
52 | }
53 | }
54 | return section
55 | }
56 |
57 | private static func widgetBannerSection() -> NSCollectionLayoutSection {
58 | let itemSize = NSCollectionLayoutSize(
59 | widthDimension: .fractionalWidth(1),
60 | heightDimension: .fractionalHeight(1)
61 | )
62 | let item = NSCollectionLayoutItem(layoutSize: itemSize)
63 | item.contentInsets = .init(top: 0, leading: 5, bottom: 0, trailing: 5)
64 |
65 | let groupSize = NSCollectionLayoutSize(
66 | widthDimension: .fractionalWidth(0.2),
67 | heightDimension: .fractionalWidth(0.3)
68 | )
69 | let group = NSCollectionLayoutGroup.horizontal(
70 | layoutSize: groupSize,
71 | subitems: [item]
72 | )
73 | let section = NSCollectionLayoutSection(group: group)
74 | let supplementaryItem = NSCollectionLayoutBoundarySupplementaryItem(
75 | layoutSize: .init(
76 | widthDimension: .fractionalWidth(1),
77 | heightDimension: .absolute(30)
78 | ),
79 | elementKind: UICollectionView.elementKindSectionHeader, alignment: .top
80 | )
81 | supplementaryItem.contentInsets = .init(
82 | top: 0,
83 | leading: 5,
84 | bottom: 0,
85 | trailing: 5
86 | )
87 | section.boundarySupplementaryItems = [supplementaryItem]
88 | section.contentInsets = .init(top: 10, leading: 5, bottom: 10, trailing: 5)
89 | section.orthogonalScrollingBehavior = .continuous
90 | return section
91 | }
92 |
93 | private static func pinterestSection(
94 | ratios: [Ratioable],
95 | contentWidth: CGFloat
96 | ) -> NSCollectionLayoutSection {
97 | let spacing: CGFloat = 5
98 | let pinterestSection = PinterestLayoutSection(
99 | columnsCount: 2,
100 | itemRatios: ratios,
101 | spacing: spacing * 2,
102 | contentWidth: contentWidth
103 | ).section
104 | let supplementaryItem = NSCollectionLayoutBoundarySupplementaryItem(
105 | layoutSize: .init(
106 | widthDimension: .fractionalWidth(1),
107 | heightDimension: .absolute(30)
108 | ),
109 | elementKind: UICollectionView.elementKindSectionHeader, alignment: .top
110 | )
111 | supplementaryItem.contentInsets = .init(
112 | top: 0,
113 | leading: spacing,
114 | bottom: 0,
115 | trailing: spacing
116 | )
117 | pinterestSection.boundarySupplementaryItems = [supplementaryItem]
118 | return pinterestSection
119 | }
120 |
121 | }
122 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/Pinterest/PinterestLayoutSection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PinterestLayoutSection.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 01.02.2023.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 |
11 | final class PinterestLayoutSection {
12 |
13 | var section: NSCollectionLayoutSection {
14 | let section = NSCollectionLayoutSection(group: customLayoutGroup)
15 | section.contentInsets = .init(top: 0, leading: padding, bottom: 0, trailing: padding)
16 | return section
17 | }
18 |
19 | //MARK: - Private methods
20 |
21 | private let numberOfColumns: Int
22 | private let itemRatios: [Ratioable]
23 | private let spacing: CGFloat
24 | private let contentWidth: CGFloat
25 |
26 | private var padding: CGFloat {
27 | spacing / 2
28 | }
29 |
30 | // Padding around cells equal to the distance between cells
31 | private var insets: NSDirectionalEdgeInsets {
32 | return .init(top: padding, leading: padding, bottom: padding, trailing: padding)
33 | }
34 |
35 | private lazy var frames: [CGRect] = {
36 | calculateFrames()
37 | }()
38 |
39 | // Max height for section
40 | private lazy var sectionHeight: CGFloat = {
41 | (frames
42 | .map(\.maxY)
43 | .max() ?? 0
44 | ) + insets.bottom
45 | }()
46 |
47 | private lazy var customLayoutGroup: NSCollectionLayoutGroup = {
48 | let layoutSize = NSCollectionLayoutSize(
49 | widthDimension: .fractionalWidth(1.0),
50 | heightDimension: .absolute(sectionHeight)
51 | )
52 | return NSCollectionLayoutGroup.custom(layoutSize: layoutSize) { _ in
53 | self.frames.map { .init(frame: $0) }
54 | }
55 | }()
56 |
57 | init(
58 | columnsCount: Int,
59 | itemRatios: [Ratioable],
60 | spacing: CGFloat,
61 | contentWidth: CGFloat
62 | ) {
63 | self.numberOfColumns = columnsCount
64 | self.itemRatios = itemRatios
65 | self.spacing = spacing
66 | self.contentWidth = contentWidth
67 | }
68 |
69 | private func calculateFrames() -> [CGRect] {
70 | var contentHeight: CGFloat = 0
71 |
72 | // Subtract the margin from the total width and divide by the number of columns
73 | let columnWidth = (contentWidth - insets.leading - insets.trailing) / CGFloat(numberOfColumns)
74 |
75 | // Stores x-coordinate offset for each column. Not changing
76 | let xOffset = (0.. 0 else { return nil }
121 | var min = first
122 | var index = 0
123 |
124 | indices.forEach { i in
125 | let currentItem = self[i]
126 | if let minumum = min, currentItem < minumum {
127 | min = currentItem
128 | index = i
129 | }
130 | }
131 |
132 | return index
133 | }
134 | }
135 |
136 | private extension CGRect {
137 | func setHeight(ratio: CGFloat) -> CGRect {
138 | .init(x: minX, y: minY, width: width, height: width / ratio)
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/Pinterest/PinterestViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewController.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 01.02.2023.
6 | //
7 |
8 | import UIKit
9 | import SnapKit
10 | import Combine
11 |
12 | typealias DataSource = UICollectionViewDiffableDataSource
13 | typealias DataSourceSnapshot = NSDiffableDataSourceSnapshot
14 |
15 | enum Section: Int, CaseIterable {
16 | case carousel
17 | case widget
18 | case pinterest
19 | }
20 |
21 | final class PinterestViewController: UIViewController, UICollectionViewDelegate {
22 |
23 | private enum Const {
24 | static let cellId = "cellId"
25 | static let headerId = "headerId"
26 | }
27 |
28 | private let viewModel: PinterestViewModel
29 | private var cancellables = Set()
30 |
31 | init(viewModel: PinterestViewModel) {
32 | self.viewModel = viewModel
33 | super.init(nibName: nil, bundle: nil)
34 | }
35 |
36 | required init?(coder: NSCoder) {
37 | fatalError("init(coder:) has not been implemented")
38 | }
39 |
40 | private lazy var collectionView: UICollectionView = {
41 | let collectionView = UICollectionView(
42 | frame: .zero,
43 | collectionViewLayout: UICollectionViewLayout()
44 | )
45 | collectionView.delegate = self
46 | collectionView.showsHorizontalScrollIndicator = false
47 | collectionView.register(
48 | PictureCell.self,
49 | forCellWithReuseIdentifier: Const.cellId
50 | )
51 | collectionView.register(
52 | HeaderView.self,
53 | forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
54 | withReuseIdentifier: Const.headerId
55 | )
56 | return collectionView
57 | }()
58 |
59 | override func viewDidLoad() {
60 | super.viewDidLoad()
61 | configureUI()
62 | configureRefresh()
63 | configureDataSource()
64 | viewModel
65 | .loadImages(animatingDifferences: false)
66 | .sink { [weak self] ratios in
67 | self?.configureLayout(ratios: ratios)
68 | }
69 | .store(in: &cancellables)
70 | }
71 |
72 | private func configureUI() {
73 | title = "Pinterest Compositional Layout"
74 | view.backgroundColor = .black
75 | view.addSubview(collectionView)
76 | collectionView.snp.makeConstraints { make in
77 | make.edges.equalToSuperview()
78 | }
79 | }
80 |
81 | private func configureRefresh() {
82 | let refreshControl = UIRefreshControl()
83 | refreshControl.addTarget(self, action: #selector(handleRefresh), for: .valueChanged)
84 | refreshControl.tintColor = .gray
85 | collectionView.refreshControl = refreshControl
86 | }
87 |
88 | @objc
89 | private func handleRefresh() {
90 | guard !viewModel.isRefreshing else { return }
91 | viewModel
92 | .refresh()
93 | .sink { [weak self] ratios in
94 | self?.configureLayout(ratios: ratios)
95 | self?.collectionView.refreshControl?.endRefreshing()
96 | }
97 | .store(in: &cancellables)
98 | }
99 |
100 | private func configureLayout(ratios: [Ratioable]) {
101 | let layout = CustomCompositionalLayout.layout(
102 | ratios: ratios,
103 | contentWidth: view.frame.width
104 | )
105 | collectionView.setCollectionViewLayout(layout, animated: true)
106 | }
107 |
108 | }
109 |
110 | extension PinterestViewController {
111 |
112 | private func configureDataSource() {
113 | viewModel.dataSource = DataSource(
114 | collectionView: collectionView,
115 | cellProvider: { [weak self] (collectionView, indexPath, model) -> PictureCell? in
116 | guard let self,
117 | let section = Section(rawValue: indexPath.section)
118 | else { return .init() }
119 | let cell = collectionView.dequeueReusableCell(
120 | withReuseIdentifier: Const.cellId, for: indexPath) as! PictureCell
121 | cell.imageView.image = UIImage(blurHash: model.blurHash, size: model.blurHashSize)
122 | self.viewModel.loadImage(for: indexPath.item, inSection: section)
123 | .delay(for: 2, scheduler: DispatchQueue.main)
124 | .sink { _ in }
125 | receiveValue: { data in
126 |
127 | cell.imageView.image = UIImage(data: data)
128 | }
129 | .store(in: &self.cancellables)
130 | return cell
131 | }
132 | )
133 | viewModel.dataSource.supplementaryViewProvider = { (collectionView, kind, indexPath) -> UICollectionReusableView? in
134 | guard let section = Section(rawValue: indexPath.section),
135 | let header: HeaderView = collectionView.dequeueReusableSupplementaryView(
136 | ofKind: UICollectionView.elementKindSectionHeader,
137 | withReuseIdentifier: Const.headerId,
138 | for: indexPath
139 | ) as? HeaderView
140 | else { return .init() }
141 | switch section {
142 | case .carousel:
143 | break
144 | case .widget:
145 | header.titleLabel.text = "Widget"
146 | case .pinterest:
147 | header.titleLabel.text = "Pinterest"
148 | }
149 | return header
150 | }
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/PinterestCompositionalLayout/BlurHashDecode/BlurHashDecode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BlurHashDecode.swift
3 | // PinterestCompositionalLayout
4 | //
5 | // Created by Vadim Chistiakov on 05.02.2023.
6 | //
7 | /*
8 | # BlurHash for iOS, in Swift
9 |
10 | ## Standalone decoder and encoder
11 |
12 | [BlurHashDecode.swift](BlurHashDecode.swift) and [BlurHashEncode.swift](BlurHashEncode.swift) contain a decoder
13 | and encoder for BlurHash to and from `UIImage`. Both files are completeiy standalone, and can simply be copied into your
14 | project directly.
15 |
16 | ### Decoding
17 |
18 | [BlurHashDecode.swift](BlurHashDecode.swift) implements the following extension on `UIImage`:
19 |
20 | public convenience init?(blurHash: String, size: CGSize, punch: Float = 1)
21 |
22 | This creates a UIImage containing the placeholder image decoded from the BlurHash string, or returns nil if decoding failed.
23 | The parameters are:
24 |
25 | * `blurHash` - A string containing the BlurHash.
26 | * `size` - The requested output size. You should keep this small, and let UIKit scale it up for you. 32 pixels wide is plenty.
27 | * `punch` - Adjusts the contrast of the output image. Tweak it if you want a different look for your placeholders.
28 | */
29 |
30 | import UIKit
31 |
32 | //TODO: - Refactor like class-service
33 |
34 | extension UIImage {
35 | convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
36 | guard blurHash.count >= 6 else { return nil }
37 |
38 | let sizeFlag = String(blurHash[0]).decode83()
39 | let numY = (sizeFlag / 9) + 1
40 | let numX = (sizeFlag % 9) + 1
41 |
42 | let quantisedMaximumValue = String(blurHash[1]).decode83()
43 | let maximumValue = Float(quantisedMaximumValue + 1) / 166
44 |
45 | guard blurHash.count == 4 + 2 * numX * numY else { return nil }
46 |
47 | let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
48 | if i == 0 {
49 | let value = String(blurHash[2 ..< 6]).decode83()
50 | return decodeDC(value)
51 | } else {
52 | let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83()
53 | return decodeAC(value, maximumValue: maximumValue * punch)
54 | }
55 | }
56 |
57 | let width = Int(size.width)
58 | let height = Int(size.height)
59 | let bytesPerRow = width * 3
60 | guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height)
61 | else { return nil }
62 |
63 | CFDataSetLength(data, bytesPerRow * height)
64 | guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }
65 |
66 | for y in 0 ..< height {
67 | for x in 0 ..< width {
68 | var r: Float = 0
69 | var g: Float = 0
70 | var b: Float = 0
71 |
72 | for j in 0 ..< numY {
73 | for i in 0 ..< numX {
74 | let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height))
75 | let colour = colours[i + j * numX]
76 | r += colour.0 * basis
77 | g += colour.1 * basis
78 | b += colour.2 * basis
79 | }
80 | }
81 |
82 | let intR = UInt8(linearTosRGB(r))
83 | let intG = UInt8(linearTosRGB(g))
84 | let intB = UInt8(linearTosRGB(b))
85 |
86 | pixels[3 * x + 0 + y * bytesPerRow] = intR
87 | pixels[3 * x + 1 + y * bytesPerRow] = intG
88 | pixels[3 * x + 2 + y * bytesPerRow] = intB
89 | }
90 | }
91 |
92 | let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
93 |
94 | guard let provider = CGDataProvider(data: data) else { return nil }
95 | guard let cgImage = CGImage(
96 | width: width,
97 | height: height,
98 | bitsPerComponent: 8,
99 | bitsPerPixel: 24,
100 | bytesPerRow: bytesPerRow,
101 | space: CGColorSpaceCreateDeviceRGB(),
102 | bitmapInfo: bitmapInfo,
103 | provider: provider,
104 | decode: nil,
105 | shouldInterpolate: true,
106 | intent: .defaultIntent
107 | ) else { return nil }
108 |
109 | self.init(cgImage: cgImage)
110 | }
111 | }
112 |
113 | private func decodeDC(_ value: Int) -> (Float, Float, Float) {
114 | let intR = value >> 16
115 | let intG = (value >> 8) & 255
116 | let intB = value & 255
117 | return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
118 | }
119 |
120 | private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
121 | let quantR = value / (19 * 19)
122 | let quantG = (value / 19) % 19
123 | let quantB = value % 19
124 |
125 | let rgb = (
126 | signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
127 | signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
128 | signPow((Float(quantB) - 9) / 9, 2) * maximumValue
129 | )
130 |
131 | return rgb
132 | }
133 |
134 | private func signPow(_ value: Float, _ exp: Float) -> Float {
135 | copysign(pow(abs(value), exp), value)
136 | }
137 |
138 | private func linearTosRGB(_ value: Float) -> Int {
139 | let v = max(0, min(1, value))
140 | if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
141 | else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
142 | }
143 |
144 | private func sRGBToLinear(_ value: Type) -> Float {
145 | let v = Float(Int64(value)) / 255
146 | if v <= 0.04045 { return v / 12.92 }
147 | else { return pow((v + 0.055) / 1.055, 2.4) }
148 | }
149 |
150 | private let encodeCharacters: [String] = {
151 | "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
152 | .map { String($0) }
153 | }()
154 |
155 | private let decodeCharacters: [String: Int] = {
156 | var dict: [String: Int] = [:]
157 | for (index, character) in encodeCharacters.enumerated() {
158 | dict[character] = index
159 | }
160 | return dict
161 | }()
162 |
163 | extension String {
164 | func decode83() -> Int {
165 | var value: Int = 0
166 | for character in self {
167 | if let digit = decodeCharacters[String(character)] {
168 | value = value * 83 + digit
169 | }
170 | }
171 | return value
172 | }
173 | }
174 |
175 | private extension String {
176 | subscript (offset: Int) -> Character {
177 | return self[index(startIndex, offsetBy: offset)]
178 | }
179 |
180 | subscript (bounds: CountableClosedRange) -> Substring {
181 | let start = index(startIndex, offsetBy: bounds.lowerBound)
182 | let end = index(startIndex, offsetBy: bounds.upperBound)
183 | return self[start...end]
184 | }
185 |
186 | subscript (bounds: CountableRange) -> Substring {
187 | let start = index(startIndex, offsetBy: bounds.lowerBound)
188 | let end = index(startIndex, offsetBy: bounds.upperBound)
189 | return self[start..