├── 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 | ![ezgif-4-dbd8ab7133](https://user-images.githubusercontent.com/22453570/218311471-b10d9b9f-475d-427d-8bbf-d3ee89a8bef4.gif) 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..