├── .gitignore ├── CombineUIKit ├── Resources │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ └── Info.plist ├── App │ ├── SceneDelegate.swift │ └── AppDelegate.swift ├── Shared │ ├── Photo.swift │ └── Requests.swift └── Scenes │ └── Search │ ├── PhotoCell.swift │ ├── SearchViewModel.swift │ ├── SearchViewController.swift │ ├── CustomGridLayout.swift │ └── Base.lproj │ └── Search.storyboard ├── CombineUIKit.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── xcuserdata │ └── gregprice.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist └── project.pbxproj ├── CombineUIKitTests ├── Info.plist └── CombineUIKitTests.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /CombineUIKit/Resources/Config.xcconfig 2 | -------------------------------------------------------------------------------- /CombineUIKit/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CombineUIKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CombineUIKit/Resources/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 | -------------------------------------------------------------------------------- /CombineUIKit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CombineUIKit.xcodeproj/xcuserdata/gregprice.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | CombineUIKit.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /CombineUIKit/App/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // CombineUIKit 4 | // 5 | // Created by Greg Price on 30/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | 14 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 15 | guard let _ = (scene as? UIWindowScene) else { return } 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /CombineUIKit/App/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // CombineUIKit 4 | // 5 | // Created by Greg Price on 30/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | @main 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 14 | return true 15 | } 16 | 17 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 18 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /CombineUIKit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "CombineCocoa", 6 | "repositoryURL": "https://github.com/CombineCommunity/CombineCocoa", 7 | "state": { 8 | "branch": null, 9 | "revision": "749c16693b3a6fa1af2b95256fda734e7ec2258b", 10 | "version": "0.2.2" 11 | } 12 | }, 13 | { 14 | "package": "CombineDataSources", 15 | "repositoryURL": "https://github.com/CombineCommunity/CombineDataSources", 16 | "state": { 17 | "branch": null, 18 | "revision": "362795e0336f2c1637036f692a3b5d05806302b4", 19 | "version": "0.2.5" 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /CombineUIKitTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /CombineUIKit/Shared/Photo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Photo.swift 3 | // CombineUIKit 4 | // 5 | // Created by Greg Price on 30/03/2021. 6 | // 7 | 8 | struct SearchPhotos: Decodable { 9 | let results: [Photo] 10 | } 11 | 12 | extension SearchPhotos { 13 | static var emptyResults: SearchPhotos { 14 | SearchPhotos(results: []) 15 | } 16 | } 17 | 18 | struct Photo: Decodable { 19 | let id: String 20 | let urls: PhotoUrls 21 | } 22 | 23 | struct PhotoUrls: Decodable { 24 | let raw: String 25 | let full: String 26 | let regular: String 27 | let small: String 28 | let thumb: String 29 | } 30 | 31 | extension Photo: Hashable, Equatable { 32 | static func == (lhs: Photo, rhs: Photo) -> Bool { 33 | lhs.id == rhs.id 34 | } 35 | } 36 | 37 | extension PhotoUrls: Hashable, Equatable { 38 | static func == (lhs: PhotoUrls, rhs: PhotoUrls) -> Bool { 39 | lhs.raw == rhs.raw 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /CombineUIKitTests/CombineUIKitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CombineUIKitTests.swift 3 | // CombineUIKitTests 4 | // 5 | // Created by Greg Price on 30/03/2021. 6 | // 7 | 8 | import XCTest 9 | @testable import CombineUIKit 10 | 11 | class CombineUIKitTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | } 25 | 26 | func testPerformanceExample() throws { 27 | // This is an example of a performance test case. 28 | self.measure { 29 | // Put the code you want to measure the time of here. 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /CombineUIKit/Scenes/Search/PhotoCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoCell.swift 3 | // CombineUIKit 4 | // 5 | // Created by Greg Price on 31/03/2021. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | final class PhotoCell: UICollectionViewCell { 12 | 13 | @IBOutlet weak var imageView: UIImageView! 14 | @Published private var image: UIImage? = nil 15 | 16 | private var subscriptions = Set() 17 | 18 | override func prepareForReuse() { 19 | super.prepareForReuse() 20 | subscriptions = Set() 21 | } 22 | 23 | func bind(_ photo: Photo) { 24 | guard let url = URL(string: photo.urls.regular) else { 25 | imageView.image = nil 26 | return 27 | } 28 | 29 | URLSession.shared 30 | .dataTaskPublisher(for: url) 31 | .map(\.data) 32 | .map { UIImage(data: $0) } 33 | .replaceError(with: nil) 34 | .receive(on: DispatchQueue.main) 35 | .assign(to: &$image) 36 | 37 | $image 38 | .sink { [weak self] image in 39 | self?.imageView.image = image 40 | } 41 | .store(in: &subscriptions) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CombineUIKit/Scenes/Search/SearchViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchViewModel.swift 3 | // CombineUIKit 4 | // 5 | // Created by Greg Price on 30/03/2021. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | final class SearchViewModel { 12 | 13 | @Published var photos: [Photo] = [] 14 | @Published var searching: Bool = false 15 | 16 | func bind(searchQuery: AnyPublisher) { 17 | 18 | let search = searchQuery 19 | .debounce(for: .seconds(0.5), scheduler: DispatchQueue.global()) 20 | .map { URLRequest.searchPhotos(query: $0) } 21 | .share() 22 | 23 | let photos = search 24 | .map { API.publisher(for: $0) } 25 | .switchToLatest() 26 | .decode(type: SearchPhotos.self, decoder: API.jsonDecoder) 27 | .replaceError(with: .emptyResults) 28 | .share() 29 | 30 | photos 31 | .map(\.results) 32 | .receive(on: DispatchQueue.main) 33 | .assign(to: &$photos) 34 | 35 | search 36 | .map { _ in true } 37 | .merge(with: photos 38 | .map { _ in false } 39 | .delay(for: .seconds(0.5), scheduler: DispatchQueue.global())) 40 | .replaceError(with: false) 41 | .receive(on: DispatchQueue.main) 42 | .assign(to: &$searching) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /CombineUIKit/Scenes/Search/SearchViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchViewController.swift 3 | // CombineUIKit 4 | // 5 | // Created by Greg Price on 30/03/2021. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | import CombineCocoa 11 | import CombineDataSources 12 | 13 | final class SearchViewController: UIViewController { 14 | 15 | @IBOutlet weak var activityView: UIActivityIndicatorView! 16 | @IBOutlet weak var searchBar: UISearchBar! 17 | @IBOutlet weak var collectionView: UICollectionView! 18 | 19 | private let viewModel = SearchViewModel() 20 | private var subscriptions = Set() 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | 25 | viewModel.bind(searchQuery: searchBar.textDidChangePublisher) 26 | 27 | viewModel.$photos 28 | .bind(subscriber: collectionView.itemsSubscriber(cellIdentifier: "Cell", cellType: PhotoCell.self, cellConfig: { cell, _, photo in 29 | cell.bind(photo) 30 | })) 31 | .store(in: &subscriptions) 32 | 33 | viewModel.$searching 34 | .sink { [weak activityView] searching in 35 | searching ? activityView?.startAnimating() : activityView?.stopAnimating() 36 | } 37 | .store(in: &subscriptions) 38 | 39 | searchBar.searchButtonClickedPublisher 40 | .sink { [weak searchBar] in 41 | searchBar?.resignFirstResponder() 42 | } 43 | .store(in: &subscriptions) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /CombineUIKit/Resources/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 | -------------------------------------------------------------------------------- /CombineUIKit/Shared/Requests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Requests.swift 3 | // CombineUIKit 4 | // 5 | // Created by Greg Price on 30/03/2021. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | enum API { 12 | 13 | static let jsonDecoder: JSONDecoder = { 14 | let decoder = JSONDecoder() 15 | decoder.keyDecodingStrategy = .convertFromSnakeCase 16 | return decoder 17 | }() 18 | 19 | static func publisher(for request: URLRequest) -> AnyPublisher { 20 | URLSession.shared 21 | .dataTaskPublisher(for: request) 22 | .map(\.data) 23 | .eraseToAnyPublisher() 24 | } 25 | } 26 | 27 | extension URLComponents { 28 | 29 | static func unsplash(path: String, queryItems: [String: String]) -> URLComponents { 30 | var components = URLComponents() 31 | components.scheme = "https" 32 | components.host = "api.unsplash.com" 33 | components.path = path 34 | components.queryItems = queryItems.map { URLQueryItem(name: $0.key, value: $0.value) } 35 | return components 36 | } 37 | } 38 | 39 | extension URLRequest { 40 | 41 | static func unsplash(url: URL) -> URLRequest { 42 | var request = URLRequest(url: url) 43 | request.setValue("v1", forHTTPHeaderField: "Accept-Version") 44 | fatalError("create your own unsplash api key -> https://unsplash.com/documentation#creating-a-developer-account") 45 | request.setValue("[ add your unsplash api key here ]", forHTTPHeaderField: "Authorization") 46 | return request 47 | } 48 | 49 | static func searchPhotos(query: String, perPage: Int = 20) -> URLRequest { 50 | let url = URLComponents.unsplash(path: "/search/photos", queryItems: ["query": query, "per_page": "\(perPage)"]).url! 51 | return .unsplash(url: url) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /CombineUIKit/Resources/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" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /CombineUIKit/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | UISceneStoryboardFile 37 | Search 38 | 39 | 40 | 41 | 42 | UIApplicationSupportsIndirectInputEvents 43 | 44 | UILaunchStoryboardName 45 | LaunchScreen 46 | UIMainStoryboardFile 47 | Search 48 | UIRequiredDeviceCapabilities 49 | 50 | armv7 51 | 52 | UISupportedInterfaceOrientations 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationLandscapeLeft 56 | UIInterfaceOrientationLandscapeRight 57 | 58 | UISupportedInterfaceOrientations~ipad 59 | 60 | UIInterfaceOrientationPortrait 61 | UIInterfaceOrientationPortraitUpsideDown 62 | UIInterfaceOrientationLandscapeLeft 63 | UIInterfaceOrientationLandscapeRight 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Combine + UIKit + MVVM 2 | 3 | An example of how to bind views / view controllers and view models using Combine and UIKit in a fully reactive way. (If you wish to run this demo, you will need to create a developer account at [Unsplash][1] and add your client ID in Requests.swift). 4 | 5 | Checkout the full post on [tapdev][2] 6 | 7 | [1]: https://unsplash.com/documentation#creating-a-developer-account 8 | [2]: https://tapdev.co/2021/03/31/a-better-way-to-structure-combine-with-uikit-in-mvvm/ 9 | 10 | ![Screenshot](https://user-images.githubusercontent.com/47152208/113141660-e0eaea80-9221-11eb-8d0f-1c8d5c9a4a7c.png) 11 | 12 | The view model has a single bind function, where all of the inputs are transformed into outputs. 13 | 14 | The inputs are user events, which in the case of this demo is a single publisher which feeds the view model with the stream of text coming from the search bar. The outputs are then generated by the view model, and made available for subscription in the view using the ```@Published``` property wrapper. Note that there are *no subscriptions being collected in the view model*. This is made possible using ```assign(to:)``` operator which republishes values to the underlying publishers of ```@Published``` properties. The outputs in this case are an array of photo model objects (the result of the API search) and a boolean value to indicate if search is taking place. 15 | 16 | ```swift 17 | final class SearchViewModel { 18 | 19 | @Published var photos: [Photo] = [] 20 | @Published var searching: Bool = false 21 | 22 | func bind(searchQuery: AnyPublisher) { 23 | 24 | let search = searchQuery 25 | .debounce(for: .seconds(0.5), scheduler: DispatchQueue.global()) 26 | .map { URLRequest.searchPhotos(query: $0) } 27 | .share() 28 | 29 | let photos = search 30 | .map { API.publisher(for: $0) } 31 | .switchToLatest() 32 | .decode(type: SearchPhotos.self, decoder: API.jsonDecoder) 33 | .replaceError(with: .emptyResults) 34 | .share() 35 | 36 | photos 37 | .map(\.results) 38 | .receive(on: DispatchQueue.main) 39 | .assign(to: &$photos) 40 | 41 | search 42 | .map { _ in true } 43 | .merge(with: photos 44 | .map { _ in false } 45 | .replaceError(with: false) 46 | .receive(on: DispatchQueue.main) 47 | .assign(to: &$searching) 48 | } 49 | } 50 | ``` 51 | 52 | The view controller then subscribes to the outputs of the view model. 53 | 54 | ```swift 55 | final class SearchViewController: UIViewController { 56 | 57 | @IBOutlet weak var activityView: UIActivityIndicatorView! 58 | @IBOutlet weak var searchBar: UISearchBar! 59 | @IBOutlet weak var collectionView: UICollectionView! 60 | 61 | private let viewModel = SearchViewModel() 62 | private var subscriptions = Set() 63 | 64 | override func viewDidLoad() { 65 | super.viewDidLoad() 66 | 67 | viewModel.bind(searchQuery: searchBar.textDidChangePublisher) 68 | 69 | viewModel.$photos 70 | .bind(subscriber: collectionView.itemsSubscriber(cellIdentifier: "Cell", cellType: PhotoCell.self, cellConfig: { cell, _, photo in 71 | cell.bind(photo) 72 | })) 73 | .store(in: &subscriptions) 74 | 75 | viewModel.$searching 76 | .sink { [weak activityView] searching in 77 | searching ? activityView?.startAnimating() : activityView?.stopAnimating() 78 | } 79 | .store(in: &subscriptions) 80 | 81 | searchBar.searchButtonClickedPublisher 82 | .sink { [weak searchBar] in 83 | searchBar?.resignFirstResponder() 84 | } 85 | .store(in: &subscriptions) 86 | } 87 | } 88 | ``` 89 | -------------------------------------------------------------------------------- /CombineUIKit/Scenes/Search/CustomGridLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomGridLayout.swift 3 | // RxRover 4 | // 5 | // Created by Greg Price on 01/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | // Ref: "For a Complex Grid, Define Cell Sizes Explicitly" 11 | // https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/layouts/customizing_collection_view_layouts 12 | 13 | enum CustomGridSegmentStyle { 14 | case oneThirdTwoThirds 15 | case twoThirdsOneThird 16 | case fullWidth 17 | } 18 | 19 | class CustomGridLayout: UICollectionViewLayout { 20 | 21 | private var contentBounds = CGRect.zero 22 | private var cachedAttributes = [UICollectionViewLayoutAttributes]() 23 | 24 | var headerHeight: CGFloat = 0 25 | var headerPadding: CGFloat = 10 26 | var segmentPadding: CGFloat = 10 27 | var segmentHeight: CGFloat = 240 28 | 29 | override func prepare() { 30 | super.prepare() 31 | guard let collectionView = collectionView else { return } 32 | collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: segmentPadding + 94, right: 0) 33 | resetCacheInfo(collectionView: collectionView) 34 | calculateGeometry(collectionView: collectionView) 35 | } 36 | 37 | private func resetCacheInfo(collectionView: UICollectionView) { 38 | cachedAttributes.removeAll() 39 | contentBounds = CGRect(origin: .zero, size: collectionView.bounds.size) 40 | } 41 | 42 | private func calculateGeometry(collectionView: UICollectionView) { 43 | let count = collectionView.numberOfItems(inSection: 0) 44 | var currentIndex = 0 45 | var segmentStyle: CustomGridSegmentStyle = count == 1 ? .fullWidth : .twoThirdsOneThird 46 | var lastFrame: CGRect = .zero 47 | let cvWidth = collectionView.bounds.size.width 48 | 49 | while currentIndex < count { 50 | var segmentFrame: CGRect = .zero 51 | if currentIndex == 0 { 52 | segmentFrame = CGRect(x: segmentPadding, y: lastFrame.maxY + headerHeight + headerPadding, width: cvWidth - (segmentPadding * 2), height: segmentHeight) 53 | } else { 54 | segmentFrame = CGRect(x: segmentPadding, y: lastFrame.maxY + segmentPadding, width: cvWidth - (segmentPadding * 2), height: segmentHeight) 55 | } 56 | 57 | var segmentRects = [CGRect]() 58 | switch segmentStyle { 59 | 60 | case .oneThirdTwoThirds: 61 | let horizontalSlices = segmentFrame.dividedIntegral(fraction: (1.0 / 3.0), from: .minXEdge, padding: segmentPadding) 62 | let verticalSlices = horizontalSlices.first.dividedIntegral(fraction: 0.5, from: .minYEdge, padding: segmentPadding) 63 | segmentRects = [verticalSlices.first, horizontalSlices.second, verticalSlices.second] 64 | 65 | case .twoThirdsOneThird: 66 | let horizontalSlices = segmentFrame.dividedIntegral(fraction: (2.0 / 3.0), from: .minXEdge, padding: segmentPadding) 67 | let verticalSlices = horizontalSlices.second.dividedIntegral(fraction: 0.5, from: .minYEdge, padding: segmentPadding) 68 | segmentRects = [horizontalSlices.first, verticalSlices.first, verticalSlices.second] 69 | 70 | case .fullWidth: 71 | segmentRects = [segmentFrame] 72 | } 73 | 74 | for rect in segmentRects { 75 | if currentIndex < count { 76 | let attributes = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: currentIndex, section: 0)) 77 | attributes.frame = rect 78 | lastFrame = attributes.frame 79 | cachedAttributes.append(attributes) 80 | contentBounds = contentBounds.union(lastFrame) 81 | currentIndex += 1 82 | } 83 | } 84 | 85 | let countModulo = count % 3 86 | let remaining = count - currentIndex 87 | if countModulo == 0 || (countModulo != 0 && remaining != 1) { 88 | if segmentStyle == .oneThirdTwoThirds { 89 | segmentStyle = .twoThirdsOneThird 90 | } else if segmentStyle == .twoThirdsOneThird { 91 | segmentStyle = .oneThirdTwoThirds 92 | } 93 | } else { 94 | segmentStyle = .fullWidth 95 | } 96 | } 97 | } 98 | 99 | override var collectionViewContentSize: CGSize { 100 | return contentBounds.size 101 | } 102 | 103 | override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { 104 | guard let collectionView = collectionView else { return false } 105 | return !newBounds.size.equalTo(collectionView.bounds.size) 106 | } 107 | 108 | override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { 109 | return cachedAttributes[indexPath.item] 110 | } 111 | 112 | override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { 113 | var attributesArray = [UICollectionViewLayoutAttributes]() 114 | guard let lastIndex = cachedAttributes.indices.last, 115 | let firstMatchIndex = binSearch(rect, start: 0, end: lastIndex) else { return attributesArray } 116 | 117 | for attributes in cachedAttributes[..= rect.minY else { break } 119 | attributesArray.append(attributes) 120 | } 121 | 122 | for attributes in cachedAttributes[firstMatchIndex...] { 123 | guard attributes.frame.minY <= rect.maxY else { break } 124 | attributesArray.append(attributes) 125 | } 126 | 127 | return attributesArray 128 | } 129 | 130 | private func binSearch(_ rect: CGRect, start: Int, end: Int) -> Int? { 131 | if end < start { return nil } 132 | let mid = (start + end) / 2 133 | let attr = cachedAttributes[mid] 134 | if attr.frame.intersects(rect) { 135 | return mid 136 | } else { 137 | if attr.frame.maxY < rect.minY { 138 | return binSearch(rect, start: (mid + 1), end: end) 139 | } else { 140 | return binSearch(rect, start: start, end: (mid - 1)) 141 | } 142 | } 143 | } 144 | } 145 | 146 | // https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/layouts/customizing_collection_view_layouts 147 | 148 | extension CGRect { 149 | 150 | func dividedIntegral(fraction: CGFloat, from fromEdge: CGRectEdge, padding: CGFloat = 10) -> (first: CGRect, second: CGRect) { 151 | let dimension: CGFloat 152 | switch fromEdge { 153 | 154 | case .minXEdge, .maxXEdge: 155 | dimension = self.size.width 156 | 157 | case .minYEdge, .maxYEdge: 158 | dimension = self.size.height 159 | } 160 | 161 | let distance = (dimension * fraction).rounded(.up) 162 | var slices = self.divided(atDistance: distance, from: fromEdge) 163 | switch fromEdge { 164 | 165 | case .minXEdge, .maxXEdge: 166 | slices.remainder.origin.x += padding 167 | slices.remainder.size.width -= padding 168 | 169 | case .minYEdge, .maxYEdge: 170 | slices.remainder.origin.y += padding 171 | slices.remainder.size.height -= padding 172 | } 173 | 174 | return (first: slices.slice, second: slices.remainder) 175 | } 176 | } 177 | 178 | -------------------------------------------------------------------------------- /CombineUIKit/Scenes/Search/Base.lproj/Search.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /CombineUIKit.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | F395A95F2613654100431A56 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F395A95E2613654100431A56 /* AppDelegate.swift */; }; 11 | F395A9612613654100431A56 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F395A9602613654100431A56 /* SceneDelegate.swift */; }; 12 | F395A9662613654100431A56 /* Search.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F395A9642613654100431A56 /* Search.storyboard */; }; 13 | F395A9682613654400431A56 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F395A9672613654400431A56 /* Assets.xcassets */; }; 14 | F395A96B2613654400431A56 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F395A9692613654400431A56 /* LaunchScreen.storyboard */; }; 15 | F395A9762613654400431A56 /* CombineUIKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F395A9752613654400431A56 /* CombineUIKitTests.swift */; }; 16 | F395A99B2613662400431A56 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F395A99A2613662400431A56 /* SearchViewController.swift */; }; 17 | F395A99F2613664700431A56 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F395A99E2613664700431A56 /* SearchViewModel.swift */; }; 18 | F395A9AD261368C900431A56 /* Photo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F395A9AC261368C900431A56 /* Photo.swift */; }; 19 | F395A9B1261369B000431A56 /* Requests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F395A9B0261369B000431A56 /* Requests.swift */; }; 20 | F3E481F52613A8BA00A2E419 /* CombineCocoa in Frameworks */ = {isa = PBXBuildFile; productRef = F3E481F42613A8BA00A2E419 /* CombineCocoa */; }; 21 | F3E481F92614824D00A2E419 /* CustomGridLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E481F82614824D00A2E419 /* CustomGridLayout.swift */; }; 22 | F3E481FE261482FD00A2E419 /* CombineDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = F3E481FD261482FD00A2E419 /* CombineDataSources */; }; 23 | F3E4820326148CB200A2E419 /* PhotoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E4820226148CB200A2E419 /* PhotoCell.swift */; }; 24 | /* End PBXBuildFile section */ 25 | 26 | /* Begin PBXContainerItemProxy section */ 27 | F395A9722613654400431A56 /* PBXContainerItemProxy */ = { 28 | isa = PBXContainerItemProxy; 29 | containerPortal = F395A9532613654100431A56 /* Project object */; 30 | proxyType = 1; 31 | remoteGlobalIDString = F395A95A2613654100431A56; 32 | remoteInfo = CombineUIKit; 33 | }; 34 | /* End PBXContainerItemProxy section */ 35 | 36 | /* Begin PBXFileReference section */ 37 | F395A95B2613654100431A56 /* CombineUIKit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CombineUIKit.app; sourceTree = BUILT_PRODUCTS_DIR; }; 38 | F395A95E2613654100431A56 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 39 | F395A9602613654100431A56 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 40 | F395A9652613654100431A56 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Search.storyboard; sourceTree = ""; }; 41 | F395A9672613654400431A56 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 42 | F395A96A2613654400431A56 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 43 | F395A96C2613654400431A56 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 44 | F395A9712613654400431A56 /* CombineUIKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CombineUIKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 45 | F395A9752613654400431A56 /* CombineUIKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineUIKitTests.swift; sourceTree = ""; }; 46 | F395A9772613654400431A56 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 47 | F395A99A2613662400431A56 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; 48 | F395A99E2613664700431A56 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; 49 | F395A9AC261368C900431A56 /* Photo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Photo.swift; sourceTree = ""; }; 50 | F395A9B0261369B000431A56 /* Requests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Requests.swift; sourceTree = ""; }; 51 | F3E481F82614824D00A2E419 /* CustomGridLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomGridLayout.swift; sourceTree = ""; }; 52 | F3E4820226148CB200A2E419 /* PhotoCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCell.swift; sourceTree = ""; }; 53 | /* End PBXFileReference section */ 54 | 55 | /* Begin PBXFrameworksBuildPhase section */ 56 | F395A9582613654100431A56 /* Frameworks */ = { 57 | isa = PBXFrameworksBuildPhase; 58 | buildActionMask = 2147483647; 59 | files = ( 60 | F3E481F52613A8BA00A2E419 /* CombineCocoa in Frameworks */, 61 | F3E481FE261482FD00A2E419 /* CombineDataSources in Frameworks */, 62 | ); 63 | runOnlyForDeploymentPostprocessing = 0; 64 | }; 65 | F395A96E2613654400431A56 /* Frameworks */ = { 66 | isa = PBXFrameworksBuildPhase; 67 | buildActionMask = 2147483647; 68 | files = ( 69 | ); 70 | runOnlyForDeploymentPostprocessing = 0; 71 | }; 72 | /* End PBXFrameworksBuildPhase section */ 73 | 74 | /* Begin PBXGroup section */ 75 | F395A9522613654100431A56 = { 76 | isa = PBXGroup; 77 | children = ( 78 | F395A95D2613654100431A56 /* CombineUIKit */, 79 | F395A9742613654400431A56 /* CombineUIKitTests */, 80 | F395A95C2613654100431A56 /* Products */, 81 | ); 82 | sourceTree = ""; 83 | }; 84 | F395A95C2613654100431A56 /* Products */ = { 85 | isa = PBXGroup; 86 | children = ( 87 | F395A95B2613654100431A56 /* CombineUIKit.app */, 88 | F395A9712613654400431A56 /* CombineUIKitTests.xctest */, 89 | ); 90 | name = Products; 91 | sourceTree = ""; 92 | }; 93 | F395A95D2613654100431A56 /* CombineUIKit */ = { 94 | isa = PBXGroup; 95 | children = ( 96 | F3E48208261491D900A2E419 /* App */, 97 | F395A9962613659500431A56 /* Scenes */, 98 | F395A999261365E300431A56 /* Shared */, 99 | F395A998261365A900431A56 /* Resources */, 100 | ); 101 | path = CombineUIKit; 102 | sourceTree = ""; 103 | }; 104 | F395A9742613654400431A56 /* CombineUIKitTests */ = { 105 | isa = PBXGroup; 106 | children = ( 107 | F395A9752613654400431A56 /* CombineUIKitTests.swift */, 108 | F395A9772613654400431A56 /* Info.plist */, 109 | ); 110 | path = CombineUIKitTests; 111 | sourceTree = ""; 112 | }; 113 | F395A9962613659500431A56 /* Scenes */ = { 114 | isa = PBXGroup; 115 | children = ( 116 | F395A9972613659D00431A56 /* Search */, 117 | ); 118 | path = Scenes; 119 | sourceTree = ""; 120 | }; 121 | F395A9972613659D00431A56 /* Search */ = { 122 | isa = PBXGroup; 123 | children = ( 124 | F395A9642613654100431A56 /* Search.storyboard */, 125 | F395A99A2613662400431A56 /* SearchViewController.swift */, 126 | F395A99E2613664700431A56 /* SearchViewModel.swift */, 127 | F3E481F82614824D00A2E419 /* CustomGridLayout.swift */, 128 | F3E4820226148CB200A2E419 /* PhotoCell.swift */, 129 | ); 130 | path = Search; 131 | sourceTree = ""; 132 | }; 133 | F395A998261365A900431A56 /* Resources */ = { 134 | isa = PBXGroup; 135 | children = ( 136 | F395A9672613654400431A56 /* Assets.xcassets */, 137 | F395A9692613654400431A56 /* LaunchScreen.storyboard */, 138 | F395A96C2613654400431A56 /* Info.plist */, 139 | ); 140 | path = Resources; 141 | sourceTree = ""; 142 | }; 143 | F395A999261365E300431A56 /* Shared */ = { 144 | isa = PBXGroup; 145 | children = ( 146 | F395A9AC261368C900431A56 /* Photo.swift */, 147 | F395A9B0261369B000431A56 /* Requests.swift */, 148 | ); 149 | path = Shared; 150 | sourceTree = ""; 151 | }; 152 | F3E48208261491D900A2E419 /* App */ = { 153 | isa = PBXGroup; 154 | children = ( 155 | F395A95E2613654100431A56 /* AppDelegate.swift */, 156 | F395A9602613654100431A56 /* SceneDelegate.swift */, 157 | ); 158 | path = App; 159 | sourceTree = ""; 160 | }; 161 | /* End PBXGroup section */ 162 | 163 | /* Begin PBXNativeTarget section */ 164 | F395A95A2613654100431A56 /* CombineUIKit */ = { 165 | isa = PBXNativeTarget; 166 | buildConfigurationList = F395A9852613654500431A56 /* Build configuration list for PBXNativeTarget "CombineUIKit" */; 167 | buildPhases = ( 168 | F395A9572613654100431A56 /* Sources */, 169 | F395A9582613654100431A56 /* Frameworks */, 170 | F395A9592613654100431A56 /* Resources */, 171 | ); 172 | buildRules = ( 173 | ); 174 | dependencies = ( 175 | ); 176 | name = CombineUIKit; 177 | packageProductDependencies = ( 178 | F3E481F42613A8BA00A2E419 /* CombineCocoa */, 179 | F3E481FD261482FD00A2E419 /* CombineDataSources */, 180 | ); 181 | productName = CombineUIKit; 182 | productReference = F395A95B2613654100431A56 /* CombineUIKit.app */; 183 | productType = "com.apple.product-type.application"; 184 | }; 185 | F395A9702613654400431A56 /* CombineUIKitTests */ = { 186 | isa = PBXNativeTarget; 187 | buildConfigurationList = F395A9882613654500431A56 /* Build configuration list for PBXNativeTarget "CombineUIKitTests" */; 188 | buildPhases = ( 189 | F395A96D2613654400431A56 /* Sources */, 190 | F395A96E2613654400431A56 /* Frameworks */, 191 | F395A96F2613654400431A56 /* Resources */, 192 | ); 193 | buildRules = ( 194 | ); 195 | dependencies = ( 196 | F395A9732613654400431A56 /* PBXTargetDependency */, 197 | ); 198 | name = CombineUIKitTests; 199 | productName = CombineUIKitTests; 200 | productReference = F395A9712613654400431A56 /* CombineUIKitTests.xctest */; 201 | productType = "com.apple.product-type.bundle.unit-test"; 202 | }; 203 | /* End PBXNativeTarget section */ 204 | 205 | /* Begin PBXProject section */ 206 | F395A9532613654100431A56 /* Project object */ = { 207 | isa = PBXProject; 208 | attributes = { 209 | LastSwiftUpdateCheck = 1240; 210 | LastUpgradeCheck = 1240; 211 | TargetAttributes = { 212 | F395A95A2613654100431A56 = { 213 | CreatedOnToolsVersion = 12.4; 214 | }; 215 | F395A9702613654400431A56 = { 216 | CreatedOnToolsVersion = 12.4; 217 | TestTargetID = F395A95A2613654100431A56; 218 | }; 219 | }; 220 | }; 221 | buildConfigurationList = F395A9562613654100431A56 /* Build configuration list for PBXProject "CombineUIKit" */; 222 | compatibilityVersion = "Xcode 9.3"; 223 | developmentRegion = en; 224 | hasScannedForEncodings = 0; 225 | knownRegions = ( 226 | en, 227 | Base, 228 | ); 229 | mainGroup = F395A9522613654100431A56; 230 | packageReferences = ( 231 | F3E481F32613A8BA00A2E419 /* XCRemoteSwiftPackageReference "CombineCocoa" */, 232 | F3E481FC261482FD00A2E419 /* XCRemoteSwiftPackageReference "CombineDataSources" */, 233 | ); 234 | productRefGroup = F395A95C2613654100431A56 /* Products */; 235 | projectDirPath = ""; 236 | projectRoot = ""; 237 | targets = ( 238 | F395A95A2613654100431A56 /* CombineUIKit */, 239 | F395A9702613654400431A56 /* CombineUIKitTests */, 240 | ); 241 | }; 242 | /* End PBXProject section */ 243 | 244 | /* Begin PBXResourcesBuildPhase section */ 245 | F395A9592613654100431A56 /* Resources */ = { 246 | isa = PBXResourcesBuildPhase; 247 | buildActionMask = 2147483647; 248 | files = ( 249 | F395A96B2613654400431A56 /* LaunchScreen.storyboard in Resources */, 250 | F395A9682613654400431A56 /* Assets.xcassets in Resources */, 251 | F395A9662613654100431A56 /* Search.storyboard in Resources */, 252 | ); 253 | runOnlyForDeploymentPostprocessing = 0; 254 | }; 255 | F395A96F2613654400431A56 /* Resources */ = { 256 | isa = PBXResourcesBuildPhase; 257 | buildActionMask = 2147483647; 258 | files = ( 259 | ); 260 | runOnlyForDeploymentPostprocessing = 0; 261 | }; 262 | /* End PBXResourcesBuildPhase section */ 263 | 264 | /* Begin PBXSourcesBuildPhase section */ 265 | F395A9572613654100431A56 /* Sources */ = { 266 | isa = PBXSourcesBuildPhase; 267 | buildActionMask = 2147483647; 268 | files = ( 269 | F395A99F2613664700431A56 /* SearchViewModel.swift in Sources */, 270 | F395A95F2613654100431A56 /* AppDelegate.swift in Sources */, 271 | F395A99B2613662400431A56 /* SearchViewController.swift in Sources */, 272 | F395A9B1261369B000431A56 /* Requests.swift in Sources */, 273 | F3E4820326148CB200A2E419 /* PhotoCell.swift in Sources */, 274 | F395A9612613654100431A56 /* SceneDelegate.swift in Sources */, 275 | F3E481F92614824D00A2E419 /* CustomGridLayout.swift in Sources */, 276 | F395A9AD261368C900431A56 /* Photo.swift in Sources */, 277 | ); 278 | runOnlyForDeploymentPostprocessing = 0; 279 | }; 280 | F395A96D2613654400431A56 /* Sources */ = { 281 | isa = PBXSourcesBuildPhase; 282 | buildActionMask = 2147483647; 283 | files = ( 284 | F395A9762613654400431A56 /* CombineUIKitTests.swift in Sources */, 285 | ); 286 | runOnlyForDeploymentPostprocessing = 0; 287 | }; 288 | /* End PBXSourcesBuildPhase section */ 289 | 290 | /* Begin PBXTargetDependency section */ 291 | F395A9732613654400431A56 /* PBXTargetDependency */ = { 292 | isa = PBXTargetDependency; 293 | target = F395A95A2613654100431A56 /* CombineUIKit */; 294 | targetProxy = F395A9722613654400431A56 /* PBXContainerItemProxy */; 295 | }; 296 | /* End PBXTargetDependency section */ 297 | 298 | /* Begin PBXVariantGroup section */ 299 | F395A9642613654100431A56 /* Search.storyboard */ = { 300 | isa = PBXVariantGroup; 301 | children = ( 302 | F395A9652613654100431A56 /* Base */, 303 | ); 304 | name = Search.storyboard; 305 | sourceTree = ""; 306 | }; 307 | F395A9692613654400431A56 /* LaunchScreen.storyboard */ = { 308 | isa = PBXVariantGroup; 309 | children = ( 310 | F395A96A2613654400431A56 /* Base */, 311 | ); 312 | name = LaunchScreen.storyboard; 313 | sourceTree = ""; 314 | }; 315 | /* End PBXVariantGroup section */ 316 | 317 | /* Begin XCBuildConfiguration section */ 318 | F395A9832613654500431A56 /* Debug */ = { 319 | isa = XCBuildConfiguration; 320 | buildSettings = { 321 | ALWAYS_SEARCH_USER_PATHS = NO; 322 | CLANG_ANALYZER_NONNULL = YES; 323 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 324 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 325 | CLANG_CXX_LIBRARY = "libc++"; 326 | CLANG_ENABLE_MODULES = YES; 327 | CLANG_ENABLE_OBJC_ARC = YES; 328 | CLANG_ENABLE_OBJC_WEAK = YES; 329 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 330 | CLANG_WARN_BOOL_CONVERSION = YES; 331 | CLANG_WARN_COMMA = YES; 332 | CLANG_WARN_CONSTANT_CONVERSION = YES; 333 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 334 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 335 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 336 | CLANG_WARN_EMPTY_BODY = YES; 337 | CLANG_WARN_ENUM_CONVERSION = YES; 338 | CLANG_WARN_INFINITE_RECURSION = YES; 339 | CLANG_WARN_INT_CONVERSION = YES; 340 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 341 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 342 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 343 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 344 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 345 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 346 | CLANG_WARN_STRICT_PROTOTYPES = YES; 347 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 348 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 349 | CLANG_WARN_UNREACHABLE_CODE = YES; 350 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 351 | COPY_PHASE_STRIP = NO; 352 | DEBUG_INFORMATION_FORMAT = dwarf; 353 | ENABLE_STRICT_OBJC_MSGSEND = YES; 354 | ENABLE_TESTABILITY = YES; 355 | GCC_C_LANGUAGE_STANDARD = gnu11; 356 | GCC_DYNAMIC_NO_PIC = NO; 357 | GCC_NO_COMMON_BLOCKS = YES; 358 | GCC_OPTIMIZATION_LEVEL = 0; 359 | GCC_PREPROCESSOR_DEFINITIONS = ( 360 | "DEBUG=1", 361 | "$(inherited)", 362 | ); 363 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 364 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 365 | GCC_WARN_UNDECLARED_SELECTOR = YES; 366 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 367 | GCC_WARN_UNUSED_FUNCTION = YES; 368 | GCC_WARN_UNUSED_VARIABLE = YES; 369 | IPHONEOS_DEPLOYMENT_TARGET = 14.4; 370 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 371 | MTL_FAST_MATH = YES; 372 | ONLY_ACTIVE_ARCH = YES; 373 | SDKROOT = iphoneos; 374 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 375 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 376 | }; 377 | name = Debug; 378 | }; 379 | F395A9842613654500431A56 /* Release */ = { 380 | isa = XCBuildConfiguration; 381 | buildSettings = { 382 | ALWAYS_SEARCH_USER_PATHS = NO; 383 | CLANG_ANALYZER_NONNULL = YES; 384 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 385 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 386 | CLANG_CXX_LIBRARY = "libc++"; 387 | CLANG_ENABLE_MODULES = YES; 388 | CLANG_ENABLE_OBJC_ARC = YES; 389 | CLANG_ENABLE_OBJC_WEAK = YES; 390 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 391 | CLANG_WARN_BOOL_CONVERSION = YES; 392 | CLANG_WARN_COMMA = YES; 393 | CLANG_WARN_CONSTANT_CONVERSION = YES; 394 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 395 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 396 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 397 | CLANG_WARN_EMPTY_BODY = YES; 398 | CLANG_WARN_ENUM_CONVERSION = YES; 399 | CLANG_WARN_INFINITE_RECURSION = YES; 400 | CLANG_WARN_INT_CONVERSION = YES; 401 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 402 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 403 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 404 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 405 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 406 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 407 | CLANG_WARN_STRICT_PROTOTYPES = YES; 408 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 409 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 410 | CLANG_WARN_UNREACHABLE_CODE = YES; 411 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 412 | COPY_PHASE_STRIP = NO; 413 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 414 | ENABLE_NS_ASSERTIONS = NO; 415 | ENABLE_STRICT_OBJC_MSGSEND = YES; 416 | GCC_C_LANGUAGE_STANDARD = gnu11; 417 | GCC_NO_COMMON_BLOCKS = YES; 418 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 419 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 420 | GCC_WARN_UNDECLARED_SELECTOR = YES; 421 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 422 | GCC_WARN_UNUSED_FUNCTION = YES; 423 | GCC_WARN_UNUSED_VARIABLE = YES; 424 | IPHONEOS_DEPLOYMENT_TARGET = 14.4; 425 | MTL_ENABLE_DEBUG_INFO = NO; 426 | MTL_FAST_MATH = YES; 427 | SDKROOT = iphoneos; 428 | SWIFT_COMPILATION_MODE = wholemodule; 429 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 430 | VALIDATE_PRODUCT = YES; 431 | }; 432 | name = Release; 433 | }; 434 | F395A9862613654500431A56 /* Debug */ = { 435 | isa = XCBuildConfiguration; 436 | buildSettings = { 437 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 438 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 439 | CODE_SIGN_STYLE = Automatic; 440 | INFOPLIST_FILE = CombineUIKit/Resources/Info.plist; 441 | LD_RUNPATH_SEARCH_PATHS = ( 442 | "$(inherited)", 443 | "@executable_path/Frameworks", 444 | ); 445 | PRODUCT_BUNDLE_IDENTIFIER = co.tapdev.CombineUIKit; 446 | PRODUCT_NAME = "$(TARGET_NAME)"; 447 | SWIFT_VERSION = 5.0; 448 | TARGETED_DEVICE_FAMILY = "1,2"; 449 | }; 450 | name = Debug; 451 | }; 452 | F395A9872613654500431A56 /* Release */ = { 453 | isa = XCBuildConfiguration; 454 | buildSettings = { 455 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 456 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 457 | CODE_SIGN_STYLE = Automatic; 458 | INFOPLIST_FILE = CombineUIKit/Resources/Info.plist; 459 | LD_RUNPATH_SEARCH_PATHS = ( 460 | "$(inherited)", 461 | "@executable_path/Frameworks", 462 | ); 463 | PRODUCT_BUNDLE_IDENTIFIER = co.tapdev.CombineUIKit; 464 | PRODUCT_NAME = "$(TARGET_NAME)"; 465 | SWIFT_VERSION = 5.0; 466 | TARGETED_DEVICE_FAMILY = "1,2"; 467 | }; 468 | name = Release; 469 | }; 470 | F395A9892613654500431A56 /* Debug */ = { 471 | isa = XCBuildConfiguration; 472 | buildSettings = { 473 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 474 | BUNDLE_LOADER = "$(TEST_HOST)"; 475 | CODE_SIGN_STYLE = Automatic; 476 | INFOPLIST_FILE = CombineUIKitTests/Info.plist; 477 | IPHONEOS_DEPLOYMENT_TARGET = 14.4; 478 | LD_RUNPATH_SEARCH_PATHS = ( 479 | "$(inherited)", 480 | "@executable_path/Frameworks", 481 | "@loader_path/Frameworks", 482 | ); 483 | PRODUCT_BUNDLE_IDENTIFIER = co.tapdev.CombineUIKitTests; 484 | PRODUCT_NAME = "$(TARGET_NAME)"; 485 | SWIFT_VERSION = 5.0; 486 | TARGETED_DEVICE_FAMILY = "1,2"; 487 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CombineUIKit.app/CombineUIKit"; 488 | }; 489 | name = Debug; 490 | }; 491 | F395A98A2613654500431A56 /* Release */ = { 492 | isa = XCBuildConfiguration; 493 | buildSettings = { 494 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 495 | BUNDLE_LOADER = "$(TEST_HOST)"; 496 | CODE_SIGN_STYLE = Automatic; 497 | INFOPLIST_FILE = CombineUIKitTests/Info.plist; 498 | IPHONEOS_DEPLOYMENT_TARGET = 14.4; 499 | LD_RUNPATH_SEARCH_PATHS = ( 500 | "$(inherited)", 501 | "@executable_path/Frameworks", 502 | "@loader_path/Frameworks", 503 | ); 504 | PRODUCT_BUNDLE_IDENTIFIER = co.tapdev.CombineUIKitTests; 505 | PRODUCT_NAME = "$(TARGET_NAME)"; 506 | SWIFT_VERSION = 5.0; 507 | TARGETED_DEVICE_FAMILY = "1,2"; 508 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CombineUIKit.app/CombineUIKit"; 509 | }; 510 | name = Release; 511 | }; 512 | /* End XCBuildConfiguration section */ 513 | 514 | /* Begin XCConfigurationList section */ 515 | F395A9562613654100431A56 /* Build configuration list for PBXProject "CombineUIKit" */ = { 516 | isa = XCConfigurationList; 517 | buildConfigurations = ( 518 | F395A9832613654500431A56 /* Debug */, 519 | F395A9842613654500431A56 /* Release */, 520 | ); 521 | defaultConfigurationIsVisible = 0; 522 | defaultConfigurationName = Release; 523 | }; 524 | F395A9852613654500431A56 /* Build configuration list for PBXNativeTarget "CombineUIKit" */ = { 525 | isa = XCConfigurationList; 526 | buildConfigurations = ( 527 | F395A9862613654500431A56 /* Debug */, 528 | F395A9872613654500431A56 /* Release */, 529 | ); 530 | defaultConfigurationIsVisible = 0; 531 | defaultConfigurationName = Release; 532 | }; 533 | F395A9882613654500431A56 /* Build configuration list for PBXNativeTarget "CombineUIKitTests" */ = { 534 | isa = XCConfigurationList; 535 | buildConfigurations = ( 536 | F395A9892613654500431A56 /* Debug */, 537 | F395A98A2613654500431A56 /* Release */, 538 | ); 539 | defaultConfigurationIsVisible = 0; 540 | defaultConfigurationName = Release; 541 | }; 542 | /* End XCConfigurationList section */ 543 | 544 | /* Begin XCRemoteSwiftPackageReference section */ 545 | F3E481F32613A8BA00A2E419 /* XCRemoteSwiftPackageReference "CombineCocoa" */ = { 546 | isa = XCRemoteSwiftPackageReference; 547 | repositoryURL = "https://github.com/CombineCommunity/CombineCocoa"; 548 | requirement = { 549 | kind = upToNextMajorVersion; 550 | minimumVersion = 0.2.2; 551 | }; 552 | }; 553 | F3E481FC261482FD00A2E419 /* XCRemoteSwiftPackageReference "CombineDataSources" */ = { 554 | isa = XCRemoteSwiftPackageReference; 555 | repositoryURL = "https://github.com/CombineCommunity/CombineDataSources"; 556 | requirement = { 557 | kind = upToNextMajorVersion; 558 | minimumVersion = 0.2.5; 559 | }; 560 | }; 561 | /* End XCRemoteSwiftPackageReference section */ 562 | 563 | /* Begin XCSwiftPackageProductDependency section */ 564 | F3E481F42613A8BA00A2E419 /* CombineCocoa */ = { 565 | isa = XCSwiftPackageProductDependency; 566 | package = F3E481F32613A8BA00A2E419 /* XCRemoteSwiftPackageReference "CombineCocoa" */; 567 | productName = CombineCocoa; 568 | }; 569 | F3E481FD261482FD00A2E419 /* CombineDataSources */ = { 570 | isa = XCSwiftPackageProductDependency; 571 | package = F3E481FC261482FD00A2E419 /* XCRemoteSwiftPackageReference "CombineDataSources" */; 572 | productName = CombineDataSources; 573 | }; 574 | /* End XCSwiftPackageProductDependency section */ 575 | }; 576 | rootObject = F395A9532613654100431A56 /* Project object */; 577 | } 578 | --------------------------------------------------------------------------------