├── Example ├── Assets.xcassets │ ├── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── ExampleCounter │ ├── ExampleCounterViewModel.swift │ ├── ExampleCounterState.swift │ └── ExampleCounterVC.swift ├── Utilities │ └── ExampleViewModel.swift ├── ExampleList │ ├── ExampleListViewModel.swift │ ├── ExampleListState.swift │ └── CompositeVC.swift ├── ExampleLogin │ ├── UsernameState+PasswordState.swift │ └── UsernameViewModel+PasswordViewModel.swift ├── Info.plist ├── Base.lproj │ └── LaunchScreen.storyboard └── AppDelegate.swift ├── ComponentViewController-Class-Hierarchy.png ├── Cyanic.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ ├── IDETemplateMacros.plist │ └── xcschemes │ ├── Example.xcscheme │ └── Cyanic.xcscheme ├── .sourcery.yml ├── Cyanic.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Sources ├── Extensions │ ├── CGSize+Cyanic.swift │ ├── Array+Cyanic.swift │ └── Text+Cyanic.swift ├── Utilities │ ├── Constants.swift │ ├── MultiSectionController.swift │ ├── ComponentsController.swift │ └── SectionController.swift ├── Protocols │ ├── CyanicError.swift │ ├── Selectable.swift │ ├── UserInterfaceModel.swift │ ├── State.swift │ ├── ComponentLayout.swift │ ├── CyanicChildVCType.swift │ ├── Copyable.swift │ ├── StateObservableBuilder.swift │ └── Component.swift ├── Enums │ ├── CellSize.swift │ ├── Size.swift │ ├── ThrottleType.swift │ ├── Async.swift │ └── ComponentStateValidator.swift ├── Components │ ├── Protocols │ │ ├── StaticSpacingComponentType.swift │ │ ├── SizedComponentType.swift │ │ ├── ChildVCComponentType.swift │ │ ├── StaticTextComponentType.swift │ │ ├── StaticLabelComponentType.swift │ │ ├── ButtonComponentType.swift │ │ ├── ExpandableComponentType.swift │ │ ├── TextFieldComponentType.swift │ │ └── TextViewComponentType.swift │ ├── ExpandableComponent │ │ ├── DividerLine.swift │ │ ├── ExpandableComponent.swift │ │ └── ExpandableComponentLayout.swift │ ├── StaticSpacingComponent │ │ ├── StaticSpacingComponent.swift │ │ └── StaticSpacingComponentLayout.swift │ ├── ChildVCComponent │ │ ├── ChildVCComponent.swift │ │ └── ChildVCComponentLayout.swift │ ├── SizedComponent │ │ ├── SizedComponent.swift │ │ └── SizedComponentLayout.swift │ ├── StaticLabelComponent │ │ ├── StaticLabelComponentLayout.swift │ │ └── StaticLabelComponent.swift │ ├── StaticTextComponent │ │ ├── StaticTextComponent.swift │ │ └── StaticTextComponentLayout.swift │ ├── ButtonComponent │ │ ├── ButtonComponent.swift │ │ └── ButtonComponentLayout.swift │ ├── TextFieldComponent │ │ ├── CyanicTextFieldDelegateProxy.swift │ │ ├── TextFieldComponent.swift │ │ └── TextFieldComponentLayout.swift │ └── TextViewComponent │ │ ├── TextViewComponentLayout.swift │ │ ├── TextViewComponent.swift │ │ └── CyanicTextViewDelegateProxy.swift ├── Classes │ ├── AnyViewModel.swift │ ├── AnyComponent.swift │ ├── AbstractViewModel.swift │ └── CyanicViewController.swift ├── TableView │ ├── TableComponentViewController.swift │ ├── TableComponentCell.swift │ └── TableComponentSectionView.swift ├── CollectionView │ ├── CollectionComponentCell.swift │ ├── CyanicNoFadeFlowLayout.swift │ ├── CollectionComponentViewController.swift │ └── ComponentSupplementaryView.swift ├── Views │ └── ComponentView.swift └── Sourcery │ └── AutoHashableComponent+Cyanic.swift ├── Podfile ├── Supporting Files ├── FFUFComponents.h └── Info.plist ├── Tests ├── Info.plist ├── AsyncTests.swift ├── CyanicViewControllerTests.swift └── StateStoreTests.swift ├── LICENSE ├── Templates ├── CyanicComponent.xctemplate │ ├── TemplateInfo.plist │ └── ___FILEBASENAME___Component.swift ├── AutoGenerateComponent.stencil ├── AutoHashableComponent.stencil ├── AutoEquatableComponent.stencil └── AutoGenerateComponentExtensions.swifttemplate ├── .swiftlint.yml ├── Cyanic.podspec ├── Podfile.lock ├── .gitignore └── README.md /Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /ComponentViewController-Class-Hierarchy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feilfeilundfeil/Cyanic/HEAD/ComponentViewController-Class-Hierarchy.png -------------------------------------------------------------------------------- /Cyanic.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.sourcery.yml: -------------------------------------------------------------------------------- 1 | sources: 2 | - Sources/Components 3 | - Sources/Protocols 4 | templates: 5 | - Templates 6 | output: 7 | Sources/Sourcery 8 | disableCache: 9 | true 10 | args: 11 | "project": "Cyanic" 12 | "isFramework": true 13 | -------------------------------------------------------------------------------- /Cyanic.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Cyanic.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Cyanic.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/ExampleCounter/ExampleCounterViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleCounterViewModel.swift 3 | // Example 4 | // 5 | // Created by Julio Miguel Alorro on 3/27/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import Cyanic 10 | 11 | class ExampleCounterViewModel: ExampleViewModel {} 12 | -------------------------------------------------------------------------------- /Sources/Extensions/CGSize+Cyanic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 05.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import CoreGraphics 8 | 9 | extension CGSize: Hashable { 10 | 11 | public func hash(into hasher: inout Hasher) { 12 | self.height.hash(into: &hasher) 13 | self.width.hash(into: &hasher) 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | platform :ios, '10.0' 2 | 3 | use_frameworks! 4 | 5 | def pods 6 | pod 'RxSwift' 7 | pod 'RxDataSources' 8 | pod 'LayoutKit', :git => 'https://github.com/hooliooo/LayoutKit.git' 9 | pod 'Sourcery' 10 | end 11 | 12 | target 'Cyanic' do 13 | pods 14 | end 15 | 16 | target 'Tests' do 17 | pod 'Quick' 18 | pod 'Nimble' 19 | end 20 | 21 | target 'Example' do 22 | pod 'SideMenu' 23 | pod 'Alacrity' 24 | pod 'Kio' 25 | end 26 | -------------------------------------------------------------------------------- /Cyanic.xcodeproj/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // ___PACKAGENAME___ 8 | // Created by ___FULLUSERNAME___ on ___DATE___. 9 | // Licensed under the MIT license. See LICENSE file 10 | // 11 | 12 | 13 | -------------------------------------------------------------------------------- /Supporting Files/FFUFComponents.h: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic.h 3 | // Cyanic 4 | // 5 | // Created by Julio Miguel Alorro on 2/6/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for Cyanic. 12 | FOUNDATION_EXPORT double CyanicVersionNumber; 13 | 14 | //! Project version string for Cyanic. 15 | FOUNDATION_EXPORT const unsigned char CyanicVersionString[]; 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Example/Utilities/ExampleViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleViewModel.swift 3 | // Example 4 | // 5 | // Created by Julio Miguel Alorro on 4/12/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import Cyanic 10 | 11 | public class ExampleViewModel: ViewModel { 12 | 13 | public init(initialState: ConcreteState) { 14 | super.init(initialState: initialState, isDebugMode: true) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /Example/ExampleList/ExampleListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleViewModel.swift 3 | // Example 4 | // 5 | // Created by Julio Miguel Alorro on 3/27/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import Cyanic 10 | 11 | public final class ExampleListViewModel: ExampleViewModel { 12 | 13 | // MARK: Methods 14 | func buttonWasTapped() { 15 | self.setState { $0.hasTextInTextField = !$0.hasTextInTextField } 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Utilities/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 11.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import CoreGraphics 8 | import Foundation 9 | import UIKit 10 | 11 | public enum Constants { 12 | internal static var screenWidth: CGFloat { return UIScreen.main.bounds.width } 13 | internal static var bundleIdentifier: String { return Bundle.main.bundleIdentifier ?? "de.ffuf.Cyanic" } 14 | public static var invalidID: String = UUID().uuidString 15 | } 16 | -------------------------------------------------------------------------------- /Example/ExampleCounter/ExampleCounterState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleCounterState.swift 3 | // Example 4 | // 5 | // Created by Julio Miguel Alorro on 3/27/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import Cyanic 10 | 11 | struct ExampleCounterState: State { 12 | 13 | // MARK: Static Properties 14 | static var `default`: ExampleCounterState { 15 | return ExampleCounterState(changeCount: 0) 16 | } 17 | 18 | // MARK: Stored Properties 19 | var changeCount: Int 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Protocols/CyanicError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.05.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import Foundation 8 | 9 | /** 10 | This protocol exists because using the Error protocol's localizedDescription is unreliable. Using localizedDescription 11 | to diff when overriding the default implmentation does not work as of 14.05.2019. 12 | */ 13 | public protocol CyanicError: Error { 14 | 15 | /** 16 | String representation of the CyanicError instance 17 | */ 18 | var errorDescription: String { get } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Protocols/Selectable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 05.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | /** 10 | Selectable is a protocol adopted by Components that want to utilize the collectionView(collectionView:didSelectItemAt:) 11 | method in SingleSectionComponentViewController. 12 | */ 13 | public protocol Selectable { 14 | 15 | /** 16 | Code block executed when collectionView(collectionView:didSelectItemAt:) or tableView(tableView:, didSelectRowAt:) 17 | method is called. 18 | */ 19 | func onSelect(_ view: UIView) 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Protocols/UserInterfaceModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 04.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | /** 8 | The UserInterfaceModel protocol is a workaround protocol to be able to access a Component's layout without casting 9 | it to Component (which causes a generic constraint error). 10 | */ 11 | public protocol UserInterfaceModel { 12 | 13 | // sourcery: isLayout = true 14 | // sourcery: skipHashing,skipEquality, 15 | /// The LayoutKit related class that will calculate size, location and configuration of the subviews in the ComponentCell 16 | var layout: ComponentLayout { get } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Enums/CellSize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 31.10.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import CoreGraphics 8 | 9 | /** 10 | CellSize is an enum that defines the sizing for a CollectionComponentViewController 11 | */ 12 | public enum CellSize { 13 | 14 | /** 15 | Configures the UICollectionViewCell size to have the width of the UICollectionView and the height computed by the 16 | ComponentLayout that represents the cell. 17 | */ 18 | case list 19 | 20 | /** 21 | Configures the UICollectionViewCell size to be the associated CGSize. 22 | */ 23 | case exactly(() -> CGSize) 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Example/ExampleLogin/UsernameState+PasswordState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UsernameState+PasswordState.swift 3 | // Example 4 | // 5 | // Created by Julio Miguel Alorro on 3/27/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import Cyanic 10 | 11 | public struct UsernameState: State { 12 | 13 | public static var `default`: UsernameState { 14 | return UsernameState(userName: "") 15 | } 16 | 17 | public var userName: String 18 | 19 | } 20 | 21 | public struct PasswordState: State { 22 | 23 | public static var `default`: PasswordState { 24 | return PasswordState(password: "") 25 | } 26 | 27 | public var password: String 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Example/ExampleLogin/UsernameViewModel+PasswordViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UsernameViewModel+PasswordViewModel.swift 3 | // Example 4 | // 5 | // Created by Julio Miguel Alorro on 3/27/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import Cyanic 10 | 11 | public final class UsernameViewModel: ExampleViewModel { 12 | 13 | public func setUserName(_ userName: String) { 14 | self.setState(with: { $0.userName = userName }) 15 | } 16 | 17 | } 18 | 19 | public final class PasswordViewModel: ExampleViewModel { 20 | 21 | public func setPassword(_ password: String) { 22 | self.setState(with: { $0.password = password }) 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Components/Protocols/StaticSpacingComponentType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 01.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | // sourcery: AutoEquatableComponent,AutoHashableComponent 10 | // sourcery: Component = StaticSpacingComponent,isFrameworkComponent 11 | /// StaticSpacingComponentType is a protocol for Components that represent space between 12 | /// other components / content on the screen. 13 | public protocol StaticSpacingComponentType: StaticHeightComponent { 14 | 15 | // sourcery: defaultValue = UIColor.clear 16 | /// The backgroundColor of the spacing. 17 | var backgroundColor: UIColor { get set } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Enums/Size.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import CoreGraphics 8 | 9 | /** 10 | In cases where ComponentViewController is a childViewController, it is sometimes necessary to have an exact size. 11 | This enum allows the the programmer to specify if there's an exact size for the ComponentViewController or if it 12 | should be taken cared of by UIKit. 13 | */ 14 | public enum Size { 15 | /** 16 | Size is defined by UIKit automatically. It is calculated by taking the UICollectionView's frame. 17 | */ 18 | case automatic 19 | 20 | /** 21 | Size is defined by a constant value. 22 | */ 23 | case exactly(CGSize) 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Extensions/Array+Cyanic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 23.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import RxSwift 8 | 9 | internal extension Array where Element == AnyViewModel { 10 | 11 | /** 12 | Combines the ViewModels' state observables into one combinedLatest Observable<[Any]> 13 | - Returns: 14 | The ViewModels' states as an Observable<[Any]> 15 | */ 16 | func combineStateObservables() -> Observable<[Any]> { 17 | let stateObservables: [Observable] = self.map { $0.state } 18 | let combinedStatesObservables: Observable<[Any]> = Observable.combineLatest(stateObservables) 19 | return combinedStatesObservables 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Protocols/State.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | /** 8 | State is a protocol adopted structs representing the state of your view model. 9 | */ 10 | public protocol State: Hashable, Copyable { 11 | 12 | /** 13 | The default State instance. 14 | */ 15 | static var `default`: Self { get } 16 | 17 | } 18 | 19 | /** 20 | State type that has expandable UI and needs to persist the isExpanded/isCollapsed state. 21 | */ 22 | public protocol ExpandableState: State { 23 | 24 | /** 25 | The dictionary that contains the isExpanded/isCollapsed state of ExpandableComponentTypes. 26 | */ 27 | var expandableDict: [String: Bool] { get set } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Tests/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 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/Protocols/ComponentLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 09.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import RxSwift 9 | 10 | /** 11 | A ComponentLayout is simply a Layout that is customized for Cyanic. 12 | 13 | A ComponentLayout calculates the size and location of the subviews in a given CGRect. The subviews are styled based on the 14 | data of the Component that owns this ComponentLayout 15 | */ 16 | public protocol ComponentLayout: class, Layout {} 17 | 18 | public extension ComponentLayout { 19 | 20 | /** 21 | The String identifier of the ComponentLayout used for viewReuseId in LayoutKit 22 | */ 23 | static var identifier: String { 24 | return String(describing: self) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Enums/ThrottleType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 10.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import RxSwift 8 | 9 | /** 10 | Represents the throttling option available for ComponentViewController and CyanicViewController 11 | */ 12 | public enum ThrottleType { 13 | 14 | /** 15 | Changes in State are ignored until a specified time interval has passed, the time interval is reset 16 | when changes in State occur within the time interval. 17 | */ 18 | case debounce(RxTimeInterval) 19 | 20 | /** 21 | Changes in State are ignored until a specified time interval has passed. 22 | */ 23 | case throttle(RxTimeInterval) 24 | 25 | /** 26 | Changes in State are immediately emitted. 27 | */ 28 | case none 29 | } 30 | -------------------------------------------------------------------------------- /Supporting Files/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 | FMWK 17 | CFBundleShortVersionString 18 | 0.4.5 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/Protocols/CyanicChildVCType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 07.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | /** 10 | The protocol that UIViewControllers adopt in order to be shown inside a ComponentViewController via 11 | a ChildVCComponent. Call the **cleanUp** method inside `deinit`. 12 | */ 13 | public protocol CyanicChildVCType {} 14 | 15 | public extension CyanicChildVCType where Self: UIViewController { 16 | 17 | func cleanUp() { 18 | self.view?.removeFromSuperview() 19 | self.willMove(toParent: nil) 20 | self.removeFromParent() 21 | } 22 | 23 | } 24 | 25 | /** 26 | The default CyanicChildVCType in a ChildVCComponent. This MUST be replaced by another custom CyanicChildVCType. 27 | Otherwise, it forces a fatalError. 28 | */ 29 | internal final class InvalidChildComponentVC: UIViewController, CyanicChildVCType {} 30 | -------------------------------------------------------------------------------- /Sources/Protocols/Copyable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 01.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | /** 8 | Copyable is a fork of https://gist.github.com/nicklockwood/9b4aac87e7f88c80e932ba3c843252df. 9 | Used to mutate Components and State in place with a copy (due to the value-type nature of structs) 10 | */ 11 | public protocol Copyable {} 12 | 13 | public extension Copyable { 14 | /** 15 | Creates a mutable copy of Self and mutates that copy with the closure. 16 | - Parameters: 17 | - block: The closure that mutates the mutable copy of Self 18 | - mutableSelf: The mutable copy of Self passed to the closure. 19 | - Returns: 20 | A copy of Self with the changes from the closure. 21 | */ 22 | func copy(with changes: (_ mutableSelf: inout Self) -> Void) -> Self { 23 | var mutableSelf: Self = self 24 | changes(&mutableSelf) 25 | return mutableSelf 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Feil, Feil, & Feil GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Templates/CyanicComponent.xctemplate/TemplateInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Kind 6 | Xcode.IDEFoundation.TextSubstitutionFileTemplateKind 7 | Platforms 8 | 9 | com.apple.platform.iphoneos 10 | 11 | Options 12 | 13 | 14 | Identifier 15 | productName 16 | Required 17 | 18 | Name 19 | Component Name: 20 | Description 21 | The name of the Component to be generated. E.g. 'MyCustom' results in 'MyCustomComponent'. 22 | Type 23 | text 24 | Default 25 | MyCustom 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Sources/Classes/AnyViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 23.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import RxSwift 8 | 9 | /** 10 | Type-erased wrapper for a ViewModel instance. 11 | */ 12 | public final class AnyViewModel { 13 | 14 | /** 15 | Initializer. 16 | Keeps the underlying viewModel instance in memory as an Any type. 17 | - Parameters: 18 | - viewModel: The BaseViewModel instance to be type erased. 19 | */ 20 | public init>(_ viewModel: ConcreteViewModel) { 21 | self.viewModel = viewModel 22 | self.state = viewModel.state.map({ (state: ConcreteState) -> Any in state as Any }) 23 | self.isDebugMode = viewModel.isDebugMode 24 | } 25 | 26 | /** 27 | The underlying ViewModel as an Any type 28 | */ 29 | public let viewModel: Any 30 | 31 | /** 32 | The ViewModel's State as an Observable 33 | */ 34 | public let state: Observable 35 | 36 | /** 37 | The debug mode of the underlying ViewModel instance 38 | */ 39 | public let isDebugMode: Bool 40 | 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Components/ExpandableComponent/DividerLine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 10.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | /** 10 | DividerLine is a data structure representing the characteristics of the UIView used a "divider line" on the ExpandableComponent. 11 | */ 12 | public struct DividerLine { 13 | 14 | /** 15 | Initializer. 16 | - Parameters: 17 | - backgroundColor: The UIColor of the divider line UIView. 18 | - insets: The insets of the divider line UIView. 19 | - height: The height of the divider line. 20 | */ 21 | public init(backgroundColor: UIColor, insets: UIEdgeInsets, height: CGFloat) { 22 | self.backgroundColor = backgroundColor 23 | self.insets = insets 24 | self.height = height 25 | } 26 | 27 | /** 28 | The UIColor used by the divider line UIView 29 | */ 30 | public let backgroundColor: UIColor 31 | 32 | /** 33 | The UIEdgeInsets used by the divider line UIView. 34 | */ 35 | public let insets: UIEdgeInsets 36 | 37 | /** 38 | The height used by the divider line UIView 39 | */ 40 | public let height: CGFloat 41 | 42 | } 43 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | opt_in_rules: 2 | - attributes 3 | - duplicate_imports 4 | - discouraged_optional_boolean 5 | - discouraged_optional_collection 6 | - empty_count 7 | - empty_enum_arguments 8 | # - explicit_acl 9 | - explicit_init 10 | - explicit_self 11 | # - explicit_top_level_acl 12 | # - explicit_type_interface 13 | # - file_name 14 | - first_where 15 | - force_unwrapping 16 | - identical_operands 17 | - identifier_name 18 | - implicitly_unwrapped_optional 19 | - last_where 20 | - literal_expression_end_indentation 21 | - lower_acl_than_parent 22 | - multiline_arguments 23 | - multiline_function_chains 24 | - multiline_literal_brackets 25 | - multiple_closures_with_trailing_closure 26 | - pattern_matching_keywords 27 | - unused_import 28 | - unused_private_declaration 29 | - vertical_parameter_alignment 30 | - vertical_parameter_alignment_on_call 31 | - yoda_condition 32 | 33 | disabled_rules: 34 | - identifier_name 35 | - switch_case_alignment 36 | 37 | excluded: # paths to ignore during linting. Takes precedence over `included`. 38 | - Pods 39 | - Carthage 40 | - Tests 41 | - Example 42 | - Templates 43 | 44 | line_length: 45 | ignores_function_declarations: true 46 | ignores_comments: true 47 | warning: 130 48 | 49 | file_length: 50 | warning: 390 51 | error: 400 52 | -------------------------------------------------------------------------------- /Sources/Components/StaticSpacingComponent/StaticSpacingComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | // sourcery: AutoGenerateComponent,AutoGenerateComponentExtension 10 | // sourcery: ComponentLayout = StaticSpacingComponentLayout 11 | /// StaticSpacingComponent is a Component that represents static spacing between content / other Components. 12 | public struct StaticSpacingComponent: StaticSpacingComponentType { 13 | 14 | // sourcery:inline:auto:StaticSpacingComponent.AutoGenerateComponent 15 | /** 16 | Work around Initializer because memberwise initializers are all or nothing 17 | - Parameters: 18 | - id: The unique identifier of the StaticSpacingComponent. 19 | */ 20 | public init(id: String) { 21 | self.id = id 22 | } 23 | 24 | public var id: String 25 | 26 | public var width: CGFloat = 0.0 27 | 28 | public var height: CGFloat = 44.0 29 | 30 | public var backgroundColor: UIColor = UIColor.clear 31 | 32 | // sourcery: skipHashing, skipEquality 33 | public var layout: ComponentLayout { return StaticSpacingComponentLayout(component: self) } 34 | 35 | public var identity: StaticSpacingComponent { return self } 36 | // sourcery:end 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Components/StaticSpacingComponent/StaticSpacingComponentLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | /** 11 | The StaticSpacingComponentLayout is a ComponentLayout that is a subclass of SizeLayout. 12 | Used to create, size, and arrange the subviews associated with StaticSpacingComponent. 13 | */ 14 | public final class StaticSpacingComponentLayout: SizeLayout, ComponentLayout { 15 | 16 | /** 17 | Initializer. 18 | - Parameters: 19 | - component: The StaticSpacingComponent whose properties define the UI characters of the subviews to be created. 20 | */ 21 | public init(component: StaticSpacingComponent) { 22 | let size: CGSize = component.size 23 | super.init( 24 | minWidth: size.width, 25 | maxWidth: size.width, 26 | minHeight: size.height, 27 | maxHeight: size.height, 28 | alignment: Alignment.center, 29 | flexibility: Flexibility.inflexible, 30 | viewReuseId: StaticSpacingComponentLayout.identifier, 31 | sublayout: nil, 32 | config: { (view: UIView) -> Void in 33 | view.backgroundColor = component.backgroundColor 34 | } 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Extensions/Text+Cyanic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import Foundation 8 | import LayoutKit 9 | 10 | extension Text: Hashable { 11 | 12 | /** 13 | The associated string value of either the String from .unattributed or the NSAttributedString.string from 14 | .attributed cases. 15 | */ 16 | public var value: String { 17 | switch self { 18 | case .unattributed(let string): 19 | return string 20 | case .attributed(let string): 21 | return string.string 22 | } 23 | } 24 | 25 | public static func == (lhs: Text, rhs: Text) -> Bool { 26 | switch (lhs, rhs) { 27 | case let (.attributed(lhsString), attributed(rhsString)): 28 | return lhsString.isEqual(to: rhsString) 29 | case let (.unattributed(lhsString), .unattributed(rhsString)): 30 | return lhsString == rhsString 31 | default: 32 | return false 33 | } 34 | } 35 | 36 | public func hash(into hasher: inout Hasher) { 37 | switch self { 38 | case .attributed(let string): 39 | string.hash(into: &hasher) 40 | case .unattributed(let string): 41 | string.hash(into: &hasher) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/AsyncTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncTests.swift 3 | // Tests 4 | // 5 | // Created by Julio Miguel Alorro on 5/14/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | @testable import Cyanic 12 | 13 | class AsyncTests: QuickSpec { 14 | 15 | enum Error: CyanicError { 16 | 17 | case invalidThis(String) 18 | case invalidThat(String) 19 | 20 | var errorDescription: String { 21 | switch self { 22 | case .invalidThat(let value): 23 | return "That" + value 24 | case .invalidThis(let value): 25 | return "This" + value 26 | } 27 | } 28 | 29 | } 30 | 31 | override func spec() { 32 | describe("Async failures") { 33 | it("Should equal when values are equal") { 34 | let failOne: Async = .failure(AsyncTests.Error.invalidThis("This")) 35 | let failTwo: Async = .failure(AsyncTests.Error.invalidThis("This")) 36 | 37 | expect(failOne).to(equal(failTwo)) 38 | } 39 | 40 | it("Should not equal when values are different") { 41 | let failOne: Async = .failure(AsyncTests.Error.invalidThis("This")) 42 | let failTwo: Async = .failure(AsyncTests.Error.invalidThis("That")) 43 | 44 | expect(failOne).toNot(equal(failTwo)) 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Example/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 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIRequiredDeviceCapabilities 26 | 27 | armv7 28 | 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /Templates/AutoGenerateComponent.stencil: -------------------------------------------------------------------------------- 1 | {% for type in types.all|annotated:"AutoGenerateComponent" %} 2 | // sourcery:inline:auto:{{ type.name }}.AutoGenerateComponent 3 | /** 4 | Work around Initializer because memberwise initializers are all or nothing 5 | - Parameters: 6 | - id: The unique identifier of the {{ type.name }}. 7 | */ 8 | {{ type.accessLevel }} init(id: String) { 9 | self.id = id 10 | } 11 | 12 | {% for var in type.allVariables|!annotated:"isExcluded" %} 13 | {% if var.annotations.skipHashing and var.annotations.skipEquality %} 14 | // sourcery: skipHashing, skipEquality 15 | {% elif var.annotations.skipHashing %} 16 | // sourcery: skipHashing 17 | {% elif var.annotations.skipEquality %} 18 | // sourcery: skipEquality 19 | {% endif %} 20 | {% if var.annotations.defaultValue %} 21 | {{ type.accessLevel }} {% if var.annotations.isWeak %}weak {% endif %}{% if var.annotations.isLazy %}lazy {% endif %}{% if var.isMutable %}var{% else %}let{% endif %} {{ var.name }}: {{ var.typeName }} = {{ var.annotations.defaultValue }} 22 | 23 | {% elif var.annotations.isLayout %} 24 | {{ type.accessLevel }} var layout: ComponentLayout { return {{ type.annotations.ComponentLayout }}(component: self) } 25 | 26 | {% else %} 27 | {{ type.accessLevel }} {% if var.annotations.isWeak %}weak {% endif %}{% if var.isMutable %}var{% else %}let{% endif %} {{ var.name }}: {{ var.typeName }} 28 | 29 | {% endif %} 30 | {% endfor %} 31 | {{ type.accessLevel }} var identity: {{ type.name }} { return self } 32 | 33 | // sourcery:end 34 | {% endfor %} 35 | -------------------------------------------------------------------------------- /Templates/AutoHashableComponent.stencil: -------------------------------------------------------------------------------- 1 | // sourcery:file:AutoHashableComponent+{% if argument.project %}{{ argument.project }}{% else %}Cyanic{%endif %}.swift 2 | // swiftlint:disable all 3 | {% macro combineVariableHashes variables %} 4 | {% for variable in variables where variable.readAccess != "private" and variable.readAccess != "fileprivate" %} 5 | {% if not variable.annotations.skipHashing %} 6 | {% if variable.isStatic %}type(of: self).{% else %}self.{% endif %}{{ variable.name }}.hash(into: &hasher) 7 | {% endif %} 8 | {% endfor %} 9 | {% endmacro %} 10 | // MARK: - AutoHashableComponent 11 | {% for type in types.types|!enum where type|annotated:"AutoHashableComponent" %} 12 | {% if argument.isFramework == false and type.annotations.isFrameworkComponent %} 13 | {% else %} 14 | // MARK: - {{ type.annotations.Component }} AutoHashableComponent 15 | extension {{ type.annotations.Component }}: Hashable { 16 | {{ type.accessLevel }}{% if type.based.NSObject or type.supertype.implements.AutoHashableComponent or type.supertype|annotated:"AutoHashableComponent" or type.supertype.based.Hashable %} override{% endif %} func hash(into hasher: inout Hasher) { 17 | {% if type.based.NSObject or type.supertype.implements.AutoHashableComponent or type.supertype|annotated:"AutoHashableComponent" or type.supertype.based.Hashable %} 18 | super.hash(into: hasher) 19 | {% endif %} 20 | {% if not type.kind == "protocol" %} 21 | {% call combineVariableHashes type.storedVariables %} 22 | {% else %} 23 | {% call combineVariableHashes type.allVariables %} 24 | {% endif %} 25 | } 26 | } 27 | {% endif %} 28 | {% endfor %} 29 | // sourcery:end 30 | -------------------------------------------------------------------------------- /Sources/Components/ChildVCComponent/ChildVCComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 07.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | // sourcery: AutoGenerateComponent,AutoGenerateComponentExtension,RequiredVariables 10 | // sourcery: ComponentLayout = "ChildVCComponentLayout" 11 | /// A ChildVCComponent is a Component that represents a child UIViewController presented on a UICollectionViewCell. 12 | public struct ChildVCComponent: ChildVCComponentType { 13 | 14 | // sourcery:inline:auto:ChildVCComponent.AutoGenerateComponent 15 | /** 16 | Work around Initializer because memberwise initializers are all or nothing 17 | - Parameters: 18 | - id: The unique identifier of the ChildVCComponent. 19 | */ 20 | public init(id: String) { 21 | self.id = id 22 | } 23 | 24 | // sourcery: skipHashing, skipEquality 25 | public lazy var childVC: UIViewController & CyanicChildVCType = InvalidChildComponentVC() 26 | 27 | // sourcery: skipHashing, skipEquality 28 | public weak var parentVC: UIViewController? 29 | 30 | // sourcery: skipHashing, skipEquality 31 | public var configuration: (UIViewController) -> Void = { _ in } 32 | 33 | // sourcery: skipHashing, skipEquality 34 | public var insets: UIEdgeInsets = UIEdgeInsets.zero 35 | 36 | public var id: String 37 | 38 | public var width: CGFloat = 0.0 39 | 40 | public var height: CGFloat = 44.0 41 | 42 | // sourcery: skipHashing, skipEquality 43 | public var layout: ComponentLayout { return ChildVCComponentLayout(component: self) } 44 | 45 | public var identity: ChildVCComponent { return self } 46 | // sourcery:end 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Classes/AnyComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import Differentiator 8 | import Foundation 9 | 10 | /** 11 | Type-erased wrapper for a Component instance 12 | */ 13 | public final class AnyComponent: IdentifiableType { 14 | 15 | /** 16 | Initializer. 17 | Keeps the underlying Component in memory and creates a reference to its layout and cellType. 18 | - Parameters: 19 | - component: The Component instance to be type erased. 20 | */ 21 | public init(_ component: C) { 22 | self.identity = AnyHashable(component.identity) 23 | self.id = component.id 24 | } 25 | 26 | /** 27 | The layout from the Component. 28 | */ 29 | public var layout: ComponentLayout { 30 | return (self.identity.base as! UserInterfaceModel).layout // swiftlint:disable:this force_cast 31 | } 32 | 33 | /** 34 | The underlying Component instance wrapped in an AnyHashable type erased container. 35 | */ 36 | public let identity: AnyHashable 37 | 38 | /** 39 | The unique identifier of the Component. 40 | */ 41 | public let id: String 42 | 43 | } 44 | 45 | extension AnyComponent: Hashable { 46 | 47 | public static func == (lhs: AnyComponent, rhs: AnyComponent) -> Bool { 48 | return lhs.identity == rhs.identity 49 | } 50 | 51 | public func hash(into hasher: inout Hasher) { 52 | hasher.combine(self.identity) 53 | } 54 | 55 | } 56 | 57 | extension AnyComponent: CustomStringConvertible { 58 | 59 | public var description: String { 60 | return self.identity.description 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /Example/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sources/Components/SizedComponent/SizedComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.05.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | // sourcery: AutoGenerateComponent,AutoGenerateComponentExtension 11 | // sourcery: ComponentLayout = SizedComponentLayout 12 | /// SizedComponent is a Component that represents a fixed size UIView. 13 | public struct SizedComponent: SizedComponentType { 14 | 15 | // sourcery:inline:auto:SizedComponent.AutoGenerateComponent 16 | /** 17 | Work around Initializer because memberwise initializers are all or nothing 18 | - Parameters: 19 | - id: The unique identifier of the SizedComponent. 20 | */ 21 | public init(id: String) { 22 | self.id = id 23 | } 24 | 25 | public var id: String 26 | 27 | public var width: CGFloat = 0.0 28 | 29 | // sourcery: skipHashing, skipEquality 30 | public var insets: UIEdgeInsets = UIEdgeInsets.zero 31 | 32 | public var backgroundColor: UIColor = UIColor.clear 33 | 34 | // sourcery: skipHashing, skipEquality 35 | public var alignment: Alignment = Alignment.fill 36 | 37 | // sourcery: skipHashing, skipEquality 38 | public var flexibility: Flexibility = Flexibility.flexible 39 | 40 | // sourcery: skipHashing, skipEquality 41 | public var configuration: (UIView) -> Void = { _ in } 42 | 43 | // sourcery: skipHashing, skipEquality 44 | public var viewClass: UIView.Type = UIView.self 45 | 46 | public var height: CGFloat = 44.0 47 | 48 | // sourcery: skipHashing, skipEquality 49 | public var layout: ComponentLayout { return SizedComponentLayout(component: self) } 50 | 51 | public var identity: SizedComponent { return self } 52 | // sourcery:end 53 | } 54 | -------------------------------------------------------------------------------- /Templates/CyanicComponent.xctemplate/___FILEBASENAME___Component.swift: -------------------------------------------------------------------------------- 1 | import Cyanic 2 | import LayoutKit 3 | import UIKit 4 | 5 | // sourcery: AutoEquatableComponent,AutoHashableComponent 6 | // sourcery: Component = ___VARIABLE_productName:identifier___Component 7 | public protocol ___VARIABLE_productName:identifier___ComponentType: StaticHeightComponent { 8 | 9 | // define here your component properties and annotations and run sourcery 10 | // sourcery: defaultValue = "UIColor.clear" 11 | var backgroundColor: UIColor { get set } 12 | 13 | } 14 | 15 | // sourcery: AutoGenerateComponent,AutoGenerateComponentExtension 16 | // sourcery: ComponentLayout = ___VARIABLE_productName:identifier___ComponentLayout 17 | public struct ___VARIABLE_productName:identifier___Component: ___VARIABLE_productName:identifier___ComponentType { 18 | // left initially empty 19 | } 20 | 21 | public final class ___VARIABLE_productName:identifier___ComponentLayout: SizeLayout, ComponentLayout { 22 | 23 | public init(component: ___VARIABLE_productName:identifier___Component) { 24 | let size: CGSize = CGSize(width: component.width, height: component.height) 25 | 26 | #warning("Please consider that sublayout parameter must not be nil") 27 | super.init( 28 | minWidth: size.width, 29 | maxWidth: size.width, 30 | minHeight: size.height, 31 | maxHeight: size.height, 32 | alignment: Alignment.fill, 33 | flexibility: Flexibility.flexible, 34 | viewReuseId: ___VARIABLE_productName:identifier___ComponentLayout.identifier, 35 | sublayout: nil, 36 | config: { (view: UIView) -> Void in 37 | view.backgroundColor = component.backgroundColor 38 | } 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Components/StaticLabelComponent/StaticLabelComponentLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 25.08.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | /** 11 | The StaticLabelComponentLayout is a ComponentLayout that is a subclass of SizeLayout. 12 | Arranges the content of the StaticLabelComponent. 13 | */ 14 | public final class StaticLabelComponentLayout: SizeLayout, ComponentLayout { 15 | 16 | public init(component: StaticLabelComponent) { 17 | let labelLayout: LabelLayout = LabelLayout( 18 | text: component.text, 19 | font: component.font, 20 | numberOfLines: component.numberOfLines, 21 | lineBreakMode: component.lineBreakMode, 22 | alignment: component.alignment, 23 | flexibility: component.flexibility, 24 | viewReuseId: "\(StaticLabelComponentLayout.identifier)Label", 25 | config: component.configuration 26 | ) 27 | 28 | let insetLayout: InsetLayout = InsetLayout(insets: component.insets, sublayout: labelLayout) 29 | let height: CGFloat = insetLayout 30 | .measurement(within: CGSize(width: component.width, height: CGFloat.greatestFiniteMagnitude)) 31 | .size 32 | .height 33 | 34 | let width: CGFloat = component.width 35 | 36 | super.init( 37 | minWidth: width, 38 | maxWidth: width, 39 | minHeight: height, 40 | maxHeight: height, 41 | viewReuseId: "\(StaticTextComponentLayout.identifier)Size", 42 | sublayout: insetLayout, 43 | config: { 44 | $0.backgroundColor = component.backgroundColor 45 | } 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Components/StaticTextComponent/StaticTextComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | // sourcery: AutoGenerateComponent,AutoGenerateComponentExtension 11 | // sourcery: ComponentLayout = StaticTextComponentLayout 12 | /// StaticTextComponent is a Component that represents static text to be displayed in a UICollectionViewCell. 13 | public struct StaticTextComponent: StaticTextComponentType { 14 | 15 | // sourcery:inline:auto:StaticTextComponent.AutoGenerateComponent 16 | /** 17 | Work around Initializer because memberwise initializers are all or nothing 18 | - Parameters: 19 | - id: The unique identifier of the StaticTextComponent. 20 | */ 21 | public init(id: String) { 22 | self.id = id 23 | } 24 | 25 | public var id: String 26 | 27 | public var width: CGFloat = 0.0 28 | 29 | public var text: Text = Text.unattributed("") 30 | 31 | public var font: UIFont = UIFont.systemFont(ofSize: 13.0) 32 | 33 | public var backgroundColor: UIColor = UIColor.clear 34 | 35 | public var lineFragmentPadding: CGFloat = 0.0 36 | 37 | // sourcery: skipHashing, skipEquality 38 | public var insets: UIEdgeInsets = UIEdgeInsets.zero 39 | 40 | // sourcery: skipHashing, skipEquality 41 | public var alignment: Alignment = Alignment.centerLeading 42 | 43 | // sourcery: skipHashing, skipEquality 44 | public var flexibility: Flexibility = TextViewLayoutDefaults.defaultFlexibility 45 | 46 | // sourcery: skipHashing, skipEquality 47 | public var configuration: (UITextView) -> Void = { _ in } 48 | 49 | // sourcery: skipHashing, skipEquality 50 | public var layout: ComponentLayout { return StaticTextComponentLayout(component: self) } 51 | 52 | public var identity: StaticTextComponent { return self } 53 | // sourcery:end 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Components/Protocols/SizedComponentType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.05.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | // sourcery: AutoEquatableComponent,AutoHashableComponent 11 | // sourcery: Component = SizedComponent,isFrameworkComponent 12 | /// StaticTextComponentType is a protocol for Component data structures that represent UI of a fixed size. 13 | public protocol SizedComponentType: StaticHeightComponent { 14 | 15 | // sourcery: defaultValue = UIEdgeInsets.zero 16 | // sourcery: skipHashing, skipEquality 17 | /// The insets on the UIView relative to its root UIView. 18 | /// The default value is UIEdgeInsets.zero. 19 | var insets: UIEdgeInsets { get set } 20 | 21 | // sourcery: defaultValue = UIColor.clear 22 | /// The background color of the root view. The default value is UIColor.clear. 23 | var backgroundColor: UIColor { get set } 24 | 25 | // sourcery: defaultValue = Alignment.fill 26 | // sourcery: skipHashing, skipEquality 27 | /// The alignment of the underlying SizeLayout. The default value is Alignment.fill. 28 | var alignment: Alignment { get set } 29 | 30 | // sourcery: defaultValue = Flexibility.flexible 31 | // sourcery: skipHashing, skipEquality 32 | /// The flexibility of the underlying SizeLayout. The default value is Flexibility.flexible. 33 | var flexibility: Flexibility { get set } 34 | 35 | // sourcery: defaultValue = "{ _ in }" 36 | // sourcery: skipHashing, skipEquality 37 | /// The configuration applied to the UIView. The default closure does nothing. 38 | var configuration: (UIView) -> Void { get set } 39 | 40 | // sourcery: defaultValue = "UIView.self" 41 | // sourcery: skipHashing, skipEquality 42 | /// The UIView subclass that will be created to fill the space of the root UIView. 43 | var viewClass: UIView.Type { get set } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /Cyanic.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | 3 | spec.name = "Cyanic" 4 | spec.version = "0.9.2" 5 | spec.summary = "Cyanic is a MvRx and Epoxy inspired framework that aims to build a reactive UI in a UICollectionView/UITableView." 6 | 7 | spec.description = <<-DESC 8 | Cyanic is a MvRx and Epoxy inspired framework that aims to build reactive UI in a UICollectionView/UITableView. 9 | It borrows heavily from MvRx in terms of API and structure while falling within the constraints of 10 | Swift and iOS development. It leverages RxSwift to have reactive functionality, LayoutKit to have 11 | performance close to manual layout when sizing and arranging subviews, and Sourcery for fast creation of 12 | custom components. It uses an Model-View-ViewModel (MVVM) style of architecture. 13 | DESC 14 | 15 | spec.homepage = "https://github.com/feilfeilundfeil/Cyanic" 16 | spec.license = { :type => "MIT", :file => "LICENSE" } 17 | spec.authors = "Feil, Feil, & Feil GmbH", "Julio Alorro", "Jonas Bark" 18 | spec.ios.deployment_target = "10.0" 19 | spec.source = { :git => "https://github.com/feilfeilundfeil/Cyanic.git", :tag => spec.version } 20 | spec.source_files = "Sources/**/*.swift", "Sources/Components/**/*.swift" 21 | spec.resources = ["Templates/*"] 22 | spec.requires_arc = true 23 | spec.swift_version = "5.0" 24 | 25 | spec.dependency "LayoutKit" 26 | spec.dependency "RxDataSources" 27 | spec.dependency "RxSwift" 28 | spec.dependency "Sourcery" 29 | 30 | spec.test_spec "Tests" do |test_spec| 31 | test_spec.source_files = "Tests/*.swift" 32 | test_spec.dependency "Quick" 33 | test_spec.dependency "Nimble" 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /Sources/Components/SizedComponent/SizedComponentLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.05.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | 9 | /** 10 | The SizedComponentLayout is a ComponentLayout that is a subclass of SizeLayout. 11 | Used to create, size, and arrange the subviews associated with SizedComponent. 12 | */ 13 | public final class SizedComponentLayout: SizeLayout, ComponentLayout { 14 | 15 | public init(component: SizedComponent) { 16 | let insets: UIEdgeInsets = component.insets 17 | let sizeLayout: SizeLayout = SizeLayout( 18 | minWidth: component.width, 19 | maxWidth: component.width, 20 | minHeight: component.height, 21 | maxHeight: component.height, 22 | alignment: component.alignment, 23 | viewReuseId: "\(SizedComponentLayout.identifier)ViewSize - \(component.id)", 24 | viewClass: component.viewClass, 25 | sublayout: nil, 26 | config: component.configuration 27 | ) 28 | 29 | let insetLayout: InsetLayout = InsetLayout( 30 | insets: insets, 31 | flexibility: Flexibility.inflexible, 32 | viewReuseId: "\(SizedComponentLayout.identifier)Inset - \(component.id)", 33 | sublayout: sizeLayout 34 | ) 35 | 36 | super.init( 37 | minWidth: component.width, 38 | maxWidth: component.width, 39 | minHeight: component.height, 40 | maxHeight: component.height, 41 | alignment: component.alignment, 42 | flexibility: component.flexibility, 43 | viewReuseId: "\(SizedComponentLayout.identifier)Size - \(component.id)", 44 | sublayout: insetLayout, 45 | config: { (view: UIView) -> Void in 46 | view.backgroundColor = component.backgroundColor 47 | } 48 | ) 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Alacrity (0.5.4) 3 | - Differentiator (4.0.1) 4 | - Kio (2.2.2) 5 | - LayoutKit (10.1.4) 6 | - Nimble (8.0.4) 7 | - Quick (2.2.0) 8 | - RxCocoa (5.0.1): 9 | - RxRelay (~> 5) 10 | - RxSwift (~> 5) 11 | - RxDataSources (4.0.1): 12 | - Differentiator (~> 4.0) 13 | - RxCocoa (~> 5.0) 14 | - RxSwift (~> 5.0) 15 | - RxRelay (5.0.1): 16 | - RxSwift (~> 5) 17 | - RxSwift (5.0.1) 18 | - SideMenu (6.4.7) 19 | - Sourcery (0.17.0) 20 | 21 | DEPENDENCIES: 22 | - Alacrity 23 | - Kio 24 | - LayoutKit (from `https://github.com/hooliooo/LayoutKit.git`) 25 | - Nimble 26 | - Quick 27 | - RxDataSources 28 | - RxSwift 29 | - SideMenu 30 | - Sourcery 31 | 32 | SPEC REPOS: 33 | https://github.com/CocoaPods/Specs.git: 34 | - Alacrity 35 | - Differentiator 36 | - Kio 37 | - Nimble 38 | - Quick 39 | - RxCocoa 40 | - RxDataSources 41 | - RxRelay 42 | - SideMenu 43 | - Sourcery 44 | trunk: 45 | - RxSwift 46 | 47 | EXTERNAL SOURCES: 48 | LayoutKit: 49 | :git: https://github.com/hooliooo/LayoutKit.git 50 | 51 | CHECKOUT OPTIONS: 52 | LayoutKit: 53 | :commit: 72249ccabb4f997127e1414df6aedd5422248fab 54 | :git: https://github.com/hooliooo/LayoutKit.git 55 | 56 | SPEC CHECKSUMS: 57 | Alacrity: f69c0bbe4917912d83a63c981884420f857ddeff 58 | Differentiator: 886080237d9f87f322641dedbc5be257061b0602 59 | Kio: 320b6cb88a8c9175bb72880e8a85bea66027a0a8 60 | LayoutKit: 59016c1f920c022e39dc984e7d593ae8a503c8e5 61 | Nimble: 18d5360282923225d62b09d781f63abc1a0111fc 62 | Quick: 7fb19e13be07b5dfb3b90d4f9824c855a11af40e 63 | RxCocoa: e741b9749968e8a143e2b787f1dfbff2b63d0a5c 64 | RxDataSources: efee07fa4de48477eca0a4611e6d11e2da9c1114 65 | RxRelay: 89d54507f4fd4d969e6ec1d4bd7f3673640b4640 66 | RxSwift: e2dc62b366a3adf6a0be44ba9f405efd4c94e0c4 67 | SideMenu: 928a015669c3afc201e1fe3a87f1b0bc8993a664 68 | Sourcery: 3ed61be7c8a1218fce349266139379dba477efe0 69 | 70 | PODFILE CHECKSUM: 22099bc707db07b0dcefe78335a992b98d8eb9f9 71 | 72 | COCOAPODS: 1.8.4 73 | -------------------------------------------------------------------------------- /Sources/Components/ButtonComponent/ButtonComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 27.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | // sourcery: AutoGenerateComponent,AutoGenerateComponentExtension 11 | // sourcery: ComponentLayout = ButtonComponentLayout 12 | /// ButtonComponent is a Component that represents a UIButton. 13 | public struct ButtonComponent: ButtonComponentType { 14 | 15 | // sourcery:inline:auto:ButtonComponent.AutoGenerateComponent 16 | /** 17 | Work around Initializer because memberwise initializers are all or nothing 18 | - Parameters: 19 | - id: The unique identifier of the ButtonComponent. 20 | */ 21 | public init(id: String) { 22 | self.id = id 23 | } 24 | 25 | public var type: ButtonLayoutType = ButtonLayoutType.system 26 | 27 | public var title: String = "" 28 | 29 | public var font: UIFont = UIFont.systemFont(ofSize: 15.0) 30 | 31 | // sourcery: skipHashing, skipEquality 32 | public var insets: UIEdgeInsets = UIEdgeInsets.zero 33 | 34 | public var backgroundColor: UIColor = UIColor.clear 35 | 36 | // sourcery: skipHashing, skipEquality 37 | public var alignment: Alignment = ButtonLayoutDefaults.defaultAlignment 38 | 39 | // sourcery: skipHashing, skipEquality 40 | public var flexibility: Flexibility = ButtonLayoutDefaults.defaultFlexibility 41 | 42 | // sourcery: skipHashing, skipEquality 43 | public var configuration: (UIButton) -> Void = { _ in } 44 | 45 | // sourcery: skipHashing, skipEquality 46 | public var onTap: (UIButton) -> Void = { _ in print("Hello World \(#file)") } 47 | 48 | public var id: String 49 | 50 | public var width: CGFloat = 0.0 51 | 52 | public var height: CGFloat = 44.0 53 | 54 | // sourcery: skipHashing, skipEquality 55 | public var layout: ComponentLayout { return ButtonComponentLayout(component: self) } 56 | 57 | public var identity: ButtonComponent { return self } 58 | // sourcery:end 59 | } 60 | -------------------------------------------------------------------------------- /Sources/Components/StaticLabelComponent/StaticLabelComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 25.08.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | // sourcery: AutoGenerateComponent,AutoGenerateComponentExtension 11 | // sourcery: ComponentLayout = StaticLabelComponentLayout 12 | /// A StaticLabelComponent is a Component that represents a UICollectionViewCell/UITableViewCell that displays a 13 | /// static text on a UILabel in its contentView. 14 | public struct StaticLabelComponent: StaticLabelComponentType { 15 | 16 | // sourcery:inline:auto:StaticLabelComponent.AutoGenerateComponent 17 | /** 18 | Work around Initializer because memberwise initializers are all or nothing 19 | - Parameters: 20 | - id: The unique identifier of the StaticLabelComponent. 21 | */ 22 | public init(id: String) { 23 | self.id = id 24 | } 25 | 26 | public var id: String 27 | 28 | public var width: CGFloat = 0.0 29 | 30 | public var text: Text = Text.unattributed("") 31 | 32 | public var font: UIFont = UIFont.systemFont(ofSize: 13.0) 33 | 34 | public var backgroundColor: UIColor = UIColor.clear 35 | 36 | public var numberOfLines: Int = 0 37 | 38 | public var lineBreakMode: NSLineBreakMode = NSLineBreakMode.byTruncatingTail 39 | 40 | // sourcery: skipHashing, skipEquality 41 | public var insets: UIEdgeInsets = UIEdgeInsets.zero 42 | 43 | // sourcery: skipHashing, skipEquality 44 | public var alignment: Alignment = Alignment.centerLeading 45 | 46 | // sourcery: skipHashing, skipEquality 47 | public var flexibility: Flexibility = Flexibility.flexible 48 | 49 | // sourcery: skipHashing, skipEquality 50 | public var configuration: (UILabel) -> Void = { _ in } 51 | 52 | // sourcery: skipHashing, skipEquality 53 | public var layout: ComponentLayout { return StaticLabelComponentLayout(component: self) } 54 | 55 | public var identity: StaticLabelComponent { return self } 56 | // sourcery:end 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Components/StaticTextComponent/StaticTextComponentLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | /** 11 | The StaticTextComponentLayout is a ComponentLayout that is a subclass of SizeLayout. 12 | Used to create, size, and arrange the subviews associated with StaticTextComponent. 13 | */ 14 | public final class StaticTextComponentLayout: SizeLayout, ComponentLayout { 15 | 16 | /** 17 | Initializer. 18 | - Parameters: 19 | - component: The StaticTextComponent whose properties define the UI characters of the subviews to be created. 20 | */ 21 | public init(component: StaticTextComponent) { 22 | let textLayout: TextViewLayout = TextViewLayout( 23 | text: component.text, 24 | font: component.font, 25 | lineFragmentPadding: component.lineFragmentPadding, 26 | textContainerInset: component.insets, 27 | layoutAlignment: component.alignment, 28 | flexibility: component.flexibility, 29 | viewReuseId: "\(StaticTextComponentLayout.identifier)TextView", 30 | config: { (view: UITextView) -> Void in 31 | view.backgroundColor = UIColor.clear 32 | view.isEditable = false 33 | view.isScrollEnabled = false 34 | component.configuration(view) 35 | } 36 | ) 37 | 38 | let size: CGSize = CGSize(width: component.width, height: CGFloat.greatestFiniteMagnitude) 39 | 40 | super.init( 41 | minWidth: size.width, 42 | maxWidth: size.width, 43 | minHeight: 0.0, 44 | maxHeight: size.height, 45 | viewReuseId: "\(StaticTextComponentLayout.identifier)Size", 46 | sublayout: textLayout, 47 | config: { 48 | $0.backgroundColor = component.backgroundColor 49 | } 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Pods/Pods.xcodeproj/xcuserdata 2 | *.DS_Store 3 | 4 | # Created by https://www.gitignore.io/api/xcode,swift 5 | 6 | ### Swift ### 7 | # Xcode 8 | # 9 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 10 | 11 | ## Build generated 12 | build/ 13 | DerivedData/ 14 | 15 | ## Various settings 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | xcuserdata/ 25 | 26 | ## Other 27 | *.moved-aside 28 | *.xccheckout 29 | *.xcscmblueprint 30 | 31 | ## Obj-C/Swift specific 32 | *.hmap 33 | *.ipa 34 | *.dSYM.zip 35 | *.dSYM 36 | 37 | ## Playgrounds 38 | timeline.xctimeline 39 | playground.xcworkspace 40 | 41 | # Swift Package Manager 42 | # 43 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 44 | # Packages/ 45 | # Package.pins 46 | .build/ 47 | 48 | # CocoaPods 49 | # 50 | # We recommend against adding the Pods directory to your .gitignore. However 51 | # you should judge for yourself, the pros and cons are mentioned at: 52 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 53 | # 54 | Pods/ 55 | 56 | # Carthage 57 | # 58 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 59 | # Carthage/Checkouts 60 | 61 | Carthage/Build 62 | 63 | # fastlane 64 | # 65 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 66 | # screenshots whenever they are needed. 67 | # For more information about the recommended setup visit: 68 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md 69 | 70 | fastlane/report.xml 71 | fastlane/Preview.html 72 | fastlane/screenshots 73 | fastlane/test_output 74 | 75 | 76 | ### Xcode ### 77 | build 78 | ##*.xcodeproj/* 79 | !*.xcodeproj/project.pbxproj 80 | !*.xcworkspace/contents.xcworkspacedata 81 | /*.gcno 82 | 83 | .idea/ 84 | 85 | # End of https://www.gitignore.io/api/xcode,swift 86 | -------------------------------------------------------------------------------- /Sources/Protocols/StateObservableBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 23.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import RxSwift 8 | 9 | internal protocol StateObservableBuilder { 10 | 11 | /** 12 | CombineState represents the aggregate of the Observables being monitored for new values. 13 | */ 14 | associatedtype CombinedState 15 | 16 | @discardableResult 17 | func setUpObservables(with viewModels: [AnyViewModel]) -> Observable 18 | 19 | } 20 | 21 | internal extension StateObservableBuilder { 22 | 23 | /** 24 | Throttles or debounces the observable based on the passed ThrottleType argument. 25 | 26 | This method either throttles or debounces the observable unless ThrottleType.none is passed. 27 | - Parameters: 28 | - observable: The Observable instance to be throttled, debounced ,or unchanged. 29 | - throttleType: The type of throttling to be used on the the Observable. 30 | - scheduler: The SchedulerType where the throttling / debouncing occurs. 31 | - Returns: 32 | The throttled Observable if ThrottleType.debounce or ThrottleType.throttle are used. Otherwise, it returns 33 | the same observable. 34 | */ 35 | func setUpThrottleType( 36 | on observable: Observable, 37 | throttleType: ThrottleType, 38 | scheduler: SchedulerType 39 | ) -> Observable { 40 | let throttledObservable: Observable 41 | 42 | switch throttleType { 43 | case .debounce(let timeInterval): 44 | throttledObservable = observable 45 | .debounce(timeInterval, scheduler: scheduler) 46 | 47 | case .throttle(let timeInterval): 48 | throttledObservable = observable 49 | .throttle(timeInterval, latest: true, scheduler: scheduler) 50 | 51 | case .none: 52 | throttledObservable = observable 53 | } 54 | 55 | return throttledObservable 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /Sources/Components/Protocols/ChildVCComponentType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 07.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | // sourcery: AutoEquatableComponent,AutoHashableComponent 10 | // sourcery: Component = ChildVCComponent,isFrameworkComponent 11 | /// ChildVCComponentType is a protocol for Component data structures that want to show other UIViewControllers as a 12 | /// child UIViewController to the SingleSectionComponentViewController. 13 | public protocol ChildVCComponentType: StaticHeightComponent { 14 | 15 | // sourcery: skipHashing, skipEquality, isLazy 16 | // sourcery: defaultValue = "InvalidChildComponentVC()" 17 | /// The child UIViewController instance to be shown on the UICollectionView. This must be a lazy property otherwise you 18 | /// may run into threading issues. 19 | var childVC: UIViewController & CyanicChildVCType { mutating get set } 20 | 21 | // sourcery: skipHashing, skipEquality 22 | // sourcery: isWeak 23 | /// The parent UIViewController instance of the child VC. It is usually the BaseComponentVC. 24 | var parentVC: UIViewController? { get set } 25 | 26 | // sourcery: skipHashing, skipEquality 27 | // sourcery: defaultValue = "{ _ in }" 28 | /// Additional configuration of the childVC. Called in the Layout's config closure 29 | var configuration: (UIViewController) -> Void { get set } 30 | 31 | // sourcery: skipHashing, skipEquality 32 | // sourcery: defaultValue = "UIEdgeInsets.zero" 33 | /// The insets of the child VC. The default value is UIEdgeInsets.zero. 34 | var insets: UIEdgeInsets { get set } 35 | 36 | } 37 | 38 | public extension ChildVCComponentType { 39 | 40 | /// The class name of the childVC. 41 | // sourcery: isExcluded 42 | var name: String { 43 | var component: Self = self 44 | return String(describing: Mirror(reflecting: component.childVC).subjectType) 45 | } 46 | 47 | } 48 | 49 | public extension ChildVCComponentType { 50 | 51 | // sourcery: isExcluded 52 | var description: String { 53 | return self.name 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Classes/AbstractViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 04.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import RxCocoa 8 | import RxSwift 9 | import os 10 | 11 | internal let CyanicViewModelLog: OSLog = OSLog(subsystem: Constants.bundleIdentifier, category: "ViewModel") 12 | 13 | /** 14 | AbstractViewModel is a class that provides the essential functionality that must exist in all ViewModel subclasses. 15 | */ 16 | open class AbstractViewModel: NSObject, ViewModelType { 17 | 18 | /** 19 | Initializer for the ViewModel. 20 | When instantiating the ViewModel, it is important to pass an initial State object which should represent 21 | the initial State of the current view / screen of the app. 22 | - Parameters: 23 | - initialState: The starting State of the ViewModel. 24 | */ 25 | public init(initialState: StateType, isDebugMode: Bool = false) { 26 | self.stateStore = StateStore(initialState: initialState) 27 | self.isDebugMode = isDebugMode 28 | super.init() 29 | } 30 | 31 | deinit { 32 | logDeallocation(of: self, log: CyanicViewModelLog) 33 | } 34 | 35 | /** 36 | The StateStore that manages the State of the ViewModel 37 | */ 38 | internal let stateStore: StateStore 39 | 40 | /** 41 | Indicates whether debugging functionality will be used. 42 | */ 43 | internal let isDebugMode: Bool 44 | 45 | /** 46 | The DisposeBag used to clean up any Rx related subscriptions related to the ViewModel instance. 47 | */ 48 | public let disposeBag: DisposeBag = DisposeBag() 49 | 50 | /** 51 | Accessor for the current State of the AbstractViewModel. 52 | */ 53 | public var currentState: StateType { 54 | return self.stateStore.currentState 55 | } 56 | 57 | } 58 | 59 | public extension AbstractViewModel { 60 | 61 | /** 62 | Accessor for the State Observable of the AbstractViewModel. 63 | */ 64 | var state: Observable { 65 | return self.stateStore.state 66 | } 67 | 68 | } 69 | 70 | internal protocol ViewModelType {} 71 | -------------------------------------------------------------------------------- /Templates/AutoEquatableComponent.stencil: -------------------------------------------------------------------------------- 1 | // sourcery:file:AutoEquatableComponent+{% if argument.project %}{{ argument.project }}{% else %}Cyanic{%endif %}.swift 2 | // swiftlint:disable all 3 | fileprivate func compareOptionals(lhs: T?, rhs: T?, compare: (_ lhs: T, _ rhs: T) -> Bool) -> Bool { 4 | switch (lhs, rhs) { 5 | case let (lValue?, rValue?): 6 | return compare(lValue, rValue) 7 | case (nil, nil): 8 | return true 9 | default: 10 | return false 11 | } 12 | } 13 | 14 | fileprivate func compareArrays(lhs: [T], rhs: [T], compare: (_ lhs: T, _ rhs: T) -> Bool) -> Bool { 15 | guard lhs.count == rhs.count else { return false } 16 | for (idx, lhsItem) in lhs.enumerated() { 17 | guard compare(lhsItem, rhs[idx]) else { return false } 18 | } 19 | 20 | return true 21 | } 22 | 23 | {% macro compareVariables variables %} 24 | {% for variable in variables where variable.readAccess != "private" and variable.readAccess != "fileprivate" %}{% if not variable.annotations.skipEquality %}guard {% if not variable.isOptional %}{% if not variable.annotations.arrayEquality %}lhs.{{ variable.name }} == rhs.{{ variable.name }}{% else %}compareArrays(lhs: lhs.{{ variable.name }}, rhs: rhs.{{ variable.name }}, compare: ==){% endif %}{% else %}compareOptionals(lhs: lhs.{{ variable.name }}, rhs: rhs.{{ variable.name }}, compare: ==){% endif %} else { return false }{% endif %} 25 | {% endfor %} 26 | {% endmacro %} 27 | // MARK: - AutoEquatableComponent 28 | {% for type in types.types|!enum where type|annotated:"AutoEquatableComponent" %} 29 | {% if argument.isFramework == false and type.annotations.isFrameworkComponent %} 30 | {% else %} 31 | // MARK: - {{ type.annotations.Component }} AutoEquatableComponent 32 | extension {{ type.annotations.Component }}: Equatable {} 33 | {{ type.accessLevel }} func == (lhs: {{ type.annotations.Component }}, rhs: {{ type.annotations.Component }}) -> Bool { 34 | {% if not type.kind == "protocol" %} 35 | {% call compareVariables type.storedVariables %} 36 | {% else %} 37 | {% call compareVariables type.allVariables %} 38 | {% endif %} 39 | return true 40 | } 41 | {% endif %} 42 | {% endfor %} 43 | // sourcery:end 44 | -------------------------------------------------------------------------------- /Sources/TableView/TableComponentViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | /** 10 | TableComponentViewController is a subclass of ComponentViewController. It serves as the base class for the 11 | SingleSectionTableComponentViewController and MultiSectionTableComponentViewController, therefore it contains 12 | the logic and implementations shared between the two subclasses. 13 | */ 14 | open class TableComponentViewController: ComponentViewController, UITableViewDelegate { 15 | 16 | // MARK: UIViewController Lifecycle Methods 17 | open override func viewDidLoad() { 18 | super.viewDidLoad() 19 | 20 | // Register TableComponentCell 21 | self.tableView.register(TableComponentCell.self, forCellReuseIdentifier: TableComponentCell.identifier) 22 | 23 | // Set up as the UITableView's UITableViewDelegate 24 | self.tableView.delegate = self 25 | } 26 | 27 | // MARK: Views 28 | /** 29 | The UITableView instance managed by this CollectionComponentViewController instance. 30 | */ 31 | public var tableView: UITableView { 32 | return self._listView as! UITableView // swiftlint:disable:this force_cast 33 | } 34 | 35 | // MARK: Methods 36 | /** 37 | Creates a UITableView with a UITableView.Style of plain. This method is called in the ComponentViewController's 38 | loadView method. 39 | - Returns: 40 | - UITableView instance typed as a UIView. 41 | */ 42 | open override func setUpListView() -> UIView { 43 | return UITableView( 44 | frame: CGRect.zero, 45 | style: UITableView.Style.plain 46 | ) 47 | } 48 | 49 | // MARK: UITableViewDelegate Methods 50 | open func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 51 | tableView.deselectRow(at: indexPath, animated: true) 52 | guard let component: AnyComponent = self.component(at: indexPath) else { return } 53 | guard let selectable = component.identity.base as? Selectable else { return } 54 | guard let cell = tableView.cellForRow(at: indexPath) else { return } 55 | selectable.onSelect(cell.contentView) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/TableView/TableComponentCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | /** 11 | TableComponentCell serves as the root UIView for the UI elements generated by its Layout 12 | */ 13 | open class TableComponentCell: UITableViewCell { 14 | 15 | /** 16 | The String identifier used by the TableComponentCell to register to a UITableView instance 17 | */ 18 | open class var identifier: String { 19 | return String(describing: Mirror(reflecting: self).subjectType) 20 | } 21 | 22 | // MARK: Layout 23 | /** 24 | The current ComponentLayout instance that created and arranged the subviews in the contentView of this TableComponentCell. 25 | */ 26 | private var layout: ComponentLayout? 27 | 28 | override open func prepareForReuse() { 29 | super.prepareForReuse() 30 | self.layout = nil 31 | } 32 | 33 | override public final func sizeThatFits(_ size: CGSize) -> CGSize { 34 | guard let size = self.layout?.measurement(within: size).size else { return CGSize.zero } 35 | return size 36 | } 37 | 38 | override public final var intrinsicContentSize: CGSize { 39 | return self.sizeThatFits( 40 | CGSize( 41 | width: self.bounds.width, 42 | height: self.bounds.height 43 | ) 44 | ) 45 | } 46 | 47 | /** 48 | Reads the layout from the AnyComponent instance to create the subviews in this TableComponentCell instance. This also 49 | sets the contentView.frame.size to the cell's intrinsicContentSize and calls setNeedsLayout. 50 | - Parameters: 51 | - component: The AnyComponent instance that represents this TableComponentCell 52 | */ 53 | open func configure(with component: AnyComponent) { 54 | self.layout = component.layout 55 | self.contentView.frame.size = self.intrinsicContentSize 56 | 57 | self.layout?.arrangement( 58 | origin: self.contentView.bounds.origin, 59 | width: self.contentView.bounds.size.width, 60 | height: self.contentView.bounds.size.height 61 | ) 62 | .makeViews(in: self.contentView) 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /Sources/CollectionView/CollectionComponentCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 09.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import Foundation 8 | import LayoutKit 9 | import UIKit 10 | 11 | /** 12 | CollectionComponentCell serves as the root UIView for the UI elements generated by its Layout. 13 | */ 14 | open class CollectionComponentCell: UICollectionViewCell { 15 | 16 | /** 17 | The String identifier used by the CollectionComponentCell to register to a UICollectionView instance 18 | */ 19 | open class var identifier: String { 20 | return String(describing: Mirror(reflecting: self).subjectType) 21 | } 22 | 23 | // MARK: Layout 24 | /** 25 | The current ComponentLayout instance that created and arranged the subviews in the contentView of this CollectionComponentCell. 26 | */ 27 | private var layout: ComponentLayout? 28 | 29 | override open func prepareForReuse() { 30 | super.prepareForReuse() 31 | self.layout = nil 32 | } 33 | 34 | override public final func sizeThatFits(_ size: CGSize) -> CGSize { 35 | guard let size = self.layout?.measurement(within: size).size else { return CGSize.zero } 36 | return size 37 | } 38 | 39 | override public final var intrinsicContentSize: CGSize { 40 | return self.sizeThatFits( 41 | CGSize( 42 | width: self.bounds.width, 43 | height: self.bounds.height 44 | ) 45 | ) 46 | } 47 | 48 | /** 49 | Reads the layout from the AnyComponent instance to create the subviews in this CollectionComponentCell instance. This also 50 | sets the contentView.frame.size to the cell's intrinsicContentSize and calls setNeedsLayout. 51 | - Parameters: 52 | - component: The AnyComponent instance that represents this CollectionComponentCell 53 | */ 54 | open func configure(with component: AnyComponent) { 55 | self.layout = component.layout 56 | self.contentView.frame.size = self.intrinsicContentSize 57 | 58 | self.layout?.arrangement( 59 | origin: self.contentView.bounds.origin, 60 | width: self.contentView.bounds.size.width, 61 | height: self.contentView.bounds.size.height 62 | ) 63 | .makeViews(in: self.contentView) 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /Sources/Utilities/MultiSectionController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 12.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import CoreGraphics 8 | 9 | /** 10 | MultiSectionController represents the entire data source of a UICollectionView/UITableView. It manages an Array of 11 | SectionControllers. 12 | */ 13 | public struct MultiSectionController { 14 | 15 | // MARK: Initializer 16 | /** 17 | Initializer. 18 | - Parameters: 19 | - width: The width of the UICollectionView/UITableView. MultiSectionController initializes its SectionController 20 | instances with it. 21 | */ 22 | public init(width: CGFloat) { 23 | self.width = width 24 | } 25 | 26 | // MARK: Stored Properties 27 | /** 28 | The CGFloat of the UICollectionView where the components will be displayed. 29 | */ 30 | public let width: CGFloat 31 | 32 | /** 33 | The SectionControllers representing the sections in the UICollectionView. 34 | */ 35 | public private(set) var sectionControllers: [SectionController] = [] 36 | 37 | // MARK: Computed Properties 38 | /** 39 | The height of the UICollectionView where the components will be displayed. 40 | */ 41 | public var height: CGFloat { 42 | return self.sectionControllers.reduce(into: 0.0, { (currentHeight: inout CGFloat, section: SectionController) -> Void in 43 | currentHeight += section.height 44 | }) 45 | } 46 | 47 | /** 48 | Creates a SectionController instance and configures its properties with the given closure. You must provide a 49 | sectionComponent, otherwise it will force a fatalError. 50 | - Parameters: 51 | - configuration: The closure that mutates the mutable ButtonComponent. 52 | - sectionController: The SectionController instance to be mutated/configured. 53 | - Returns: 54 | SectionController 55 | */ 56 | @discardableResult 57 | public mutating func sectionController(with configuration: (_ sectionController: inout SectionController) -> Void) -> SectionController { 58 | var sectionController: SectionController = SectionController(width: self.width) 59 | configuration(§ionController) 60 | self.sectionControllers.append(sectionController) 61 | return sectionController 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Sources/CollectionView/CyanicNoFadeFlowLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 11.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | /** 10 | The default behavior for UICollectionViewFlowLayout when its UICollectionView updates its cell is to show a "flash", 11 | sometimes this behaviour is acceptable and other times it isn't. This UICollectionViewFlowLayout will keep the alpha 12 | of updated UICollectionViewCells therefore eliminating the "flash". 13 | 14 | **Caveats** 15 | 16 | This shouldn't be used with the ExpandableComponent or any SingleSectionComponentViewController where the elements 17 | are changing. It makes the animation look broken 18 | */ 19 | open class CyanicNoFadeFlowLayout: UICollectionViewFlowLayout { 20 | 21 | open override func initialLayoutAttributesForAppearingSupplementaryElement(ofKind elementKind: String, at elementIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? { 22 | let attributes: UICollectionViewLayoutAttributes? = super 23 | .initialLayoutAttributesForAppearingSupplementaryElement(ofKind: elementKind, at: elementIndexPath) 24 | attributes?.alpha = 1.0 25 | return attributes 26 | } 27 | 28 | open override func finalLayoutAttributesForDisappearingSupplementaryElement(ofKind elementKind: String, at elementIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? { 29 | let attributes: UICollectionViewLayoutAttributes? = super 30 | .finalLayoutAttributesForDisappearingSupplementaryElement(ofKind: elementKind, at: elementIndexPath) 31 | attributes?.alpha = 1.0 32 | return attributes 33 | } 34 | 35 | override open func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? { 36 | let attributes: UICollectionViewLayoutAttributes? = super 37 | .initialLayoutAttributesForAppearingItem(at: itemIndexPath) 38 | attributes?.alpha = 1.0 39 | return attributes 40 | } 41 | 42 | override open func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? { 43 | let attributes: UICollectionViewLayoutAttributes? = super 44 | .finalLayoutAttributesForDisappearingItem(at: itemIndexPath) 45 | attributes?.alpha = 1.0 46 | return attributes 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /Sources/Components/Protocols/StaticTextComponentType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 01.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | // sourcery: AutoEquatableComponent,AutoHashableComponent 11 | // sourcery: Component = StaticTextComponent,isFrameworkComponent 12 | /// StaticTextComponentType is a protocol for Component data structures that represent static text. 13 | public protocol StaticTextComponentType: Component { 14 | 15 | // sourcery: defaultValue = "Text.unattributed("")" 16 | /// The text to be displayed on the Component as either a String or NSAttributedString. The default 17 | /// value is Text.unattributed(""). 18 | var text: Text { get set } 19 | 20 | // sourcery: defaultValue = "UIFont.systemFont(ofSize: 13.0)" 21 | /// The font of the Text. The default value is UIFont.systemFont(ofSize: 13.0). 22 | var font: UIFont { get set } 23 | 24 | // sourcery: defaultValue = UIColor.clear 25 | /// The backgroundColor for the entire content. The default value is UIColor.clear. 26 | var backgroundColor: UIColor { get set } 27 | 28 | // sourcery: defaultValue = "0.0" 29 | /// The lineFragmentPadding for the UITextView. The default value is 0.0. 30 | var lineFragmentPadding: CGFloat { get set } 31 | 32 | // sourcery: defaultValue = UIEdgeInsets.zero 33 | // sourcery: skipHashing, skipEquality 34 | /// The insets for the textContainerInset in the underlying TextViewLayout. Default value is 0.0. 35 | var insets: UIEdgeInsets { get set } 36 | 37 | // sourcery: defaultValue = Alignment.centerLeading 38 | // sourcery: skipHashing, skipEquality 39 | /// The alignment for the underlying TextViewLayout. The default value is Alignment.centerLeading. 40 | var alignment: Alignment { get set } 41 | 42 | // sourcery: defaultValue = TextViewLayoutDefaults.defaultFlexibility 43 | // sourcery: skipHashing, skipEquality 44 | /// The flexibility of the underlying TextViewLayout. The default value is TextViewLayoutDefaults.defaultFlexibility. 45 | var flexibility: Flexibility { get set } 46 | 47 | // sourcery: defaultValue = "{ _ in }" 48 | // sourcery: skipHashing, skipEquality 49 | /// The configuration applied to the UITextView. The default closure does nothing. 50 | var configuration: (UITextView) -> Void { get set } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Enums/Async.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 13.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | /** 8 | Represents some data/model that is must be retrieved some time after the screen in rendered. 9 | */ 10 | public enum Async: Hashable { 11 | 12 | // MARK: Static Methods 13 | public static func == (lhs: Async, rhs: Async) -> Bool { 14 | switch (lhs, rhs) { 15 | case let (.success(lhsValue), .success(rhsValue)): 16 | return lhsValue == rhsValue 17 | 18 | case let (.failure(lhsError), .failure(rhsError)): 19 | return type(of: lhsError) == type(of: rhsError) && 20 | lhsError.errorDescription == rhsError.errorDescription 21 | 22 | case (.loading, .loading): 23 | return true 24 | 25 | case (.uninitialized, .uninitialized): 26 | return true 27 | 28 | default: 29 | return false 30 | } 31 | } 32 | 33 | /** 34 | The data/model was successfully fetched. 35 | */ 36 | case success(T) 37 | 38 | /** 39 | An error was encountered while trying to fetched the data/model. 40 | */ 41 | case failure(CyanicError) 42 | 43 | /** 44 | The data/model is being fetched. 45 | */ 46 | case loading 47 | 48 | /** 49 | The data/model has not been fetched and the process to get it has not started 50 | */ 51 | case uninitialized 52 | 53 | // MARK: Computed Properties 54 | /** 55 | Returns the underlying model if this instance is a .success case. Otherwise, returns nil. 56 | */ 57 | public var value: T? { 58 | guard case let .success(model) = self else { return nil } 59 | return model 60 | } 61 | 62 | // MARK: Instance Methods 63 | public func hash(into hasher: inout Hasher) { 64 | switch self { 65 | case .success(let value): 66 | value.hash(into: &hasher) 67 | 68 | case .failure(let error): 69 | error.localizedDescription.hash(into: &hasher) 70 | ObjectIdentifier(type(of: self)).hash(into: &hasher) 71 | 72 | case .loading: 73 | "loading".hash(into: &hasher) 74 | 75 | case .uninitialized: 76 | "uninitialized".hash(into: &hasher) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/Components/ExpandableComponent/ExpandableComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 15.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | // sourcery: AutoGenerateComponent,AutoGenerateComponentExtension,RequiredVariables 10 | // sourcery: ComponentLayout = ExpandableComponentLayout 11 | /// An ExpandableComponent is a Component that represents an expandable UI element that shows/hides other UI elements 12 | /// grouped with it. 13 | public struct ExpandableComponent: ExpandableComponentType, Selectable { 14 | 15 | // sourcery:inline:auto:ExpandableComponent.AutoGenerateComponent 16 | /** 17 | Work around Initializer because memberwise initializers are all or nothing 18 | - Parameters: 19 | - id: The unique identifier of the ExpandableComponent. 20 | */ 21 | public init(id: String) { 22 | self.id = id 23 | } 24 | 25 | public var id: String 26 | 27 | public var width: CGFloat = 0.0 28 | 29 | public var contentLayout: ExpandableContentLayout = EmptyContentLayout() 30 | 31 | public var backgroundColor: UIColor = UIColor.clear 32 | 33 | // sourcery: skipHashing, skipEquality 34 | public var insets: UIEdgeInsets = UIEdgeInsets.zero 35 | 36 | // sourcery: skipHashing, skipEquality 37 | public var accessoryViewType: UIView.Type = UIView.self 38 | 39 | public var accessoryViewSize: CGSize = CGSize(width: 12.0, height: 12.0) 40 | 41 | // sourcery: skipHashing, skipEquality 42 | public var accessoryViewConfiguration: (UIView) -> Void = { _ in } 43 | 44 | // sourcery: skipHashing, skipEquality 45 | public var configuration: (UIView) -> Void = { _ in } 46 | 47 | public var isExpanded: Bool = false 48 | 49 | // sourcery: skipHashing, skipEquality 50 | public var setExpandableState: (String, Bool) -> Void = { (_: String, _: Bool) -> Void in 51 | fatalError("This default closure must be replaced!") 52 | } 53 | 54 | // sourcery: skipHashing, skipEquality 55 | public var dividerLine: DividerLine? 56 | 57 | public var height: CGFloat = 44.0 58 | 59 | // sourcery: skipHashing, skipEquality 60 | public var layout: ComponentLayout { return ExpandableComponentLayout(component: self) } 61 | 62 | public var identity: ExpandableComponent { return self } 63 | // sourcery:end 64 | 65 | public func onSelect(_ view: UIView) { 66 | self.setExpandableState(self.id, !self.isExpanded) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Enums/ComponentStateValidator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 13.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | public enum ComponentStateValidator { 10 | 11 | /** 12 | Checks if the Component instance has a valid identifier. 13 | - Parameters: 14 | - component: The Component instance to be checked. 15 | - Returns: 16 | Bool indicating whether or not the id is valid. 17 | */ 18 | public static func hasValidIdentifier(_ component: ConcreteComponent) -> Bool { 19 | return component.id != Constants.invalidID 20 | } 21 | 22 | /** 23 | Checks if the ExpandableComponent instance has a valid contentLayout 24 | - Parameters: 25 | - component: The ExpandableComponent instance to be checked. 26 | - Returns: 27 | Bool indicating whether or not the component's state is valid. 28 | */ 29 | public static func validateExpandableComponent(_ component: ExpandableComponent) -> Bool { 30 | let isValidContentLayout: Bool = !(component.contentLayout is EmptyContentLayout) 31 | var validations: [Bool] = [isValidContentLayout] 32 | if let contentLayout = component.contentLayout as? ImageLabelContentLayout { 33 | let size: CGSize = contentLayout.imageSize 34 | let isValid: Bool = !(size.width == 0.0 || size.height == 0.0) 35 | #if DEBUG 36 | if !isValid { 37 | let errorString: String = "Your imageSize cannot have zero values" 38 | print("ExpandableError: \(errorString)") 39 | } 40 | #endif 41 | validations.append(isValid) 42 | } 43 | 44 | return !validations.contains(false) 45 | 46 | } 47 | 48 | /** 49 | Checks if the ChildVCComponent instance has a valid contentLayout 50 | - Parameters: 51 | - component: The ChildVCComponent instance to be checked. 52 | - Returns: 53 | Bool indicating whether or not the component's state is valid. 54 | */ 55 | public static func validateChildVCComponent(_ component: ChildVCComponent) -> Bool { 56 | var component: ChildVCComponent = component 57 | let childVC: UIViewController & CyanicChildVCType = component.childVC 58 | let isInvalidChildComponentVC: Bool = childVC is InvalidChildComponentVC 59 | return !isInvalidChildComponentVC && component.parentVC != nil 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /Sources/Components/ChildVCComponent/ChildVCComponentLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 07.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | /** 11 | The ChildVCComponentLayout is a ComponentLayout that is a subclass of SizeLayout. 12 | Used to size the view property of the childVC 13 | */ 14 | public final class ChildVCComponentLayout: SizeLayout, ComponentLayout { 15 | 16 | /** 17 | Initializer. 18 | - Parameters: 19 | - component: The ChildVCComponent whose properties define the UI characters of the subviews to be created. 20 | */ 21 | public init(component: ChildVCComponent) { 22 | var component: ChildVCComponent = component 23 | let size: CGSize = component.size 24 | let insets: UIEdgeInsets = component.insets 25 | let childVC: UIViewController & CyanicChildVCType = component.childVC 26 | let parentVC: UIViewController? = component.parentVC 27 | 28 | let sizeLayout: SizeLayout = SizeLayout( 29 | size: size, 30 | viewReuseId: "\(ChildVCComponentLayout.identifier)ChildVCView", 31 | sublayout: nil, 32 | config: { [weak childVC, weak parentVC] (view: UIView) -> Void in 33 | guard let childVC = childVC, let parentVC = parentVC else { return } 34 | 35 | if childVC.parent !== parentVC { 36 | parentVC.addChild(childVC) 37 | childVC.view.frame = view.bounds 38 | view.addSubview(childVC.view) 39 | childVC.didMove(toParent: parentVC) 40 | } else { 41 | childVC.view.frame = view.bounds 42 | view.addSubview(childVC.view) 43 | } 44 | 45 | component.configuration(childVC) 46 | } 47 | ) 48 | 49 | let insetLayout: InsetLayout = InsetLayout( 50 | insets: insets, 51 | sublayout: sizeLayout 52 | ) 53 | 54 | super.init( 55 | minWidth: size.width, 56 | maxWidth: size.width, 57 | minHeight: size.height + insets.top + insets.bottom, 58 | maxHeight: size.height + insets.top + insets.bottom, 59 | viewReuseId: "\(ChildVCComponentLayout.identifier)Size", 60 | sublayout: insetLayout, 61 | config: nil 62 | ) 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /Sources/Components/Protocols/StaticLabelComponentType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 25.08.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | // sourcery: AutoEquatableComponent,AutoHashableComponent, 11 | // sourcery: Component = StaticLabelComponent,isFrameworkComponent 12 | /// A StaticLabelComponentType is a protocol adopted by Components that represent a UICollectionViewCell/UITableViewCell that 13 | /// displays static text on a UILabel in its contentView. 14 | public protocol StaticLabelComponentType: Component { 15 | 16 | // sourcery: defaultValue = "Text.unattributed("")" 17 | /// The text to be displayed on the Component as either a String or NSAttributedString. The default 18 | /// value is Text.unattributed(""). 19 | var text: Text { get set } 20 | 21 | // sourcery: defaultValue = "UIFont.systemFont(ofSize: 13.0)" 22 | /// The font of the Text. The default value is UIFont.systemFont(ofSize: 13.0). 23 | var font: UIFont { get set } 24 | 25 | // sourcery: defaultValue = UIColor.clear 26 | /// The backgroundColor for the entire content. The default value is UIColor.clear. 27 | var backgroundColor: UIColor { get set } 28 | 29 | // sourcery: defaultValue = "0" 30 | /// The numberOfLines for the UILabel. The default value is 0. 31 | var numberOfLines: Int { get set } 32 | 33 | // sourcery: defaultValue = "NSLineBreakMode.byTruncatingTail" 34 | /// The lineBreakMode for the UILabel. The default value is NSLineBreakMode.byTruncatingTail. 35 | var lineBreakMode: NSLineBreakMode { get set } 36 | 37 | // sourcery: defaultValue = UIEdgeInsets.zero 38 | // sourcery: skipHashing, skipEquality 39 | /// The insets for the content. Default value is 0.0. 40 | var insets: UIEdgeInsets { get set } 41 | 42 | // sourcery: defaultValue = Alignment.centerLeading 43 | // sourcery: skipHashing, skipEquality 44 | /// The alignment for the underlying LabelLayout. The default value is Alignment.centerLeading. 45 | var alignment: Alignment { get set } 46 | 47 | // sourcery: defaultValue = "Flexibility.flexible" 48 | // sourcery: skipHashing, skipEquality 49 | /// The flexibility of the underlying LabelLayout. The default value is Flexible.flexible. 50 | var flexibility: Flexibility { get set } 51 | 52 | // sourcery: defaultValue = "{ _ in }" 53 | // sourcery: skipHashing, skipEquality 54 | /// The configuration applied to the UILabel. The default closure does nothing. 55 | var configuration: (UILabel) -> Void { get set } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /Example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Example 4 | // 5 | // Created by Julio Miguel Alorro on 2/7/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Cyanic 11 | import RxSwift 12 | import SideMenu 13 | 14 | @UIApplicationMain 15 | class AppDelegate: UIResponder, UIApplicationDelegate { 16 | 17 | var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds) 18 | 19 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 20 | let vc: ExampleLoginVC = ExampleLoginVC( 21 | viewModelOne: UsernameViewModel(initialState: UsernameState.default), 22 | viewModelTwo: PasswordViewModel(initialState: PasswordState.default) 23 | ) 24 | 25 | let nvc: UINavigationController = UINavigationController(rootViewController: vc) 26 | 27 | self.window?.rootViewController = nvc 28 | self.window?.makeKeyAndVisible() 29 | 30 | let rightVC: SideMenuNavigationController = SideMenuNavigationController( 31 | rootViewController: ExampleCounterVC() 32 | ) 33 | SideMenuManager.default.rightMenuNavigationController = rightVC 34 | rightVC.sideMenuDelegate = self 35 | rightVC.enableSwipeToDismissGesture = false 36 | rightVC.presentationStyle = .menuSlideIn 37 | rightVC.presentationStyle.onTopShadowRadius = 0.0 38 | rightVC.presentationStyle.onTopShadowColor = UIColor.clear 39 | rightVC.presentationStyle.onTopShadowOpacity = 0.0 40 | rightVC.dismissOnPush = false 41 | rightVC.statusBarEndAlpha = 0.0 42 | 43 | // _ = Observable.interval(.seconds(1), scheduler: MainScheduler.instance) 44 | // .subscribe(onNext: { _ in 45 | // print("Resource count \(RxSwift.Resources.total)") 46 | // }) 47 | 48 | return true 49 | } 50 | 51 | } 52 | 53 | extension AppDelegate: SideMenuNavigationControllerDelegate { 54 | func sideMenuWillAppear(menu: SideMenuNavigationController, animated: Bool) { 55 | print("Side Menu Will Appear") 56 | } 57 | func sideMenuDidAppear(menu: SideMenuNavigationController, animated: Bool) { 58 | print("Side Menu Did Appear") 59 | } 60 | func sideMenuWillDisappear(menu: SideMenuNavigationController, animated: Bool) { 61 | print("Side Menu Will Disappear") 62 | } 63 | func sideMenuDidDisappear(menu: SideMenuNavigationController, animated: Bool) { 64 | print("Side Menu Did Disappear") 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /Tests/CyanicViewControllerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CyanicViewController.swift 3 | // Tests 4 | // 5 | // Created by Julio Miguel Alorro on 3/24/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | @testable import Cyanic 12 | 13 | class CyanicViewControllerTests: QuickSpec { 14 | 15 | override func spec() { 16 | describe("BaseStateListeningVC functionality") { 17 | context("When BaseStateListeningVC is initialized and view is loaded") { 18 | let vc: TestVC = TestVC() 19 | vc.viewDidLoad() 20 | it("should call invalidate to get the intial state") { 21 | expect(vc.count).toEventually(equal(1)) 22 | } 23 | 24 | it("should call invalidate when any viewModel's state changes") { 25 | vc.viewModelOne.setState(with: { $0.changeCount += 1 }) 26 | expect(vc.count).toEventually(equal(2)) 27 | expect(vc.viewModelOne.currentState.changeCount).toEventually(equal(1)) 28 | 29 | vc.viewModelTwo.setState(with: { $0.changeCount += 1}) 30 | expect(vc.count).toEventually(equal(3)) 31 | expect(vc.viewModelTwo.currentState.changeCount).toEventually(equal(1)) 32 | } 33 | } 34 | } 35 | } 36 | 37 | class TestVC: CyanicViewController { 38 | 39 | var count: Int = 0 40 | let viewModelOne: TestViewModel1 = TestViewModel1(initialState: TestVC.TestState1.default) 41 | let viewModelTwo: TestViewModel2 = TestViewModel2(initialState: TestVC.TestState2.default) 42 | 43 | override var viewModels: [AnyViewModel] { 44 | return [ 45 | self.viewModelOne.asAnyViewModel, 46 | self.viewModelTwo.asAnyViewModel 47 | ] 48 | } 49 | 50 | override func invalidate() { 51 | self.count += 1 52 | } 53 | 54 | class TestViewModel1: ViewModel {} 55 | 56 | struct TestState1: State { 57 | 58 | static var `default`: TestState1 { 59 | return TestState1(changeCount: 0) 60 | } 61 | 62 | var changeCount: Int 63 | } 64 | 65 | class TestViewModel2: ViewModel {} 66 | 67 | struct TestState2: State { 68 | 69 | static var `default`: TestState2 { 70 | return TestState2(changeCount: 0) 71 | } 72 | 73 | var changeCount: Int 74 | } 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /Sources/Protocols/Component.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 07.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import CoreGraphics 8 | import RxSwift 9 | import Differentiator 10 | 11 | /** 12 | Component is the data model representation of the UICollectionViewCell/UITableViewCell rendered on a ComponentViewController. 13 | A Component should be an immutable struct and it should contain UI specific characteristics related to the content 14 | that should be displayed in a CollectionComponentCell/TableComponentCell. 15 | 16 | A Component is what is used to diff between two collections by RxDataSources. 17 | */ 18 | public protocol Component: IdentifiableType, Copyable, UserInterfaceModel, CustomStringConvertible where Identity == Self { 19 | 20 | /// The unique id of the Component. This is mutable because structs are the only data structure 21 | /// that should conform to Component this allows deep copying of Component (assuming all the other properties are 22 | /// value types) via the Copyable protocol's copy method. 23 | var id: String { get set } 24 | 25 | // sourcery: defaultValue = "0.0" 26 | /// The width of the UICollectionViewCell/UITableViewCell that hosts the content created by the Component. 27 | /// This should not be modified because it will be set by the framework. Mutating this won't do anything. 28 | var width: CGFloat { get set } 29 | 30 | } 31 | 32 | public extension Component { 33 | 34 | // sourcery: isExcluded 35 | // sourcery: skipHashing, skipEquality 36 | /// Since Component has a generic constraint. AnyComponent is used as a type erased wrapper around it 37 | /// so Components can be grouped in Collections. 38 | var asAnyComponent: AnyComponent { 39 | return AnyComponent(self) 40 | } 41 | 42 | } 43 | 44 | extension Component { 45 | 46 | // sourcery: isExcluded 47 | // sourcery: skipHashing, skipEquality 48 | public var description: String { 49 | return self.id 50 | } 51 | 52 | } 53 | 54 | public protocol StaticHeightComponent: Component { 55 | 56 | // sourcery: defaultValue = "44.0" 57 | /// The height of the UICollectionViewCell/UITableViewCell that hosts the content created by the Component. 58 | /// The default value is 44.0 59 | var height: CGFloat { get set } 60 | 61 | } 62 | 63 | public extension StaticHeightComponent { 64 | 65 | // sourcery: isExcluded 66 | // sourcery: skipHashing, skipEquality 67 | /// The size of the StaticHeightComponent. 68 | var size: CGSize { 69 | return CGSize(width: self.width, height: self.height) 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /Sources/Views/ComponentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 07.08.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import RxCocoa 9 | import RxSwift 10 | import UIKit 11 | 12 | /** 13 | ComponentView is a UIView that acts as the root UIView for Cyanic Components. 14 | */ 15 | open class ComponentView: UIView { 16 | 17 | // MARK: Stored Properties 18 | /** 19 | The Component that arranges the subviews within this UIView instance. 20 | */ 21 | open var component: AnyComponent? { 22 | didSet { 23 | guard let component = self.component else { return } 24 | self.configure(with: component) 25 | } 26 | } 27 | 28 | public private(set) var layout: ComponentLayout? 29 | private lazy var tap: UITapGestureRecognizer = UITapGestureRecognizer() 30 | private lazy var disposeBag: DisposeBag = DisposeBag() 31 | private lazy var disposable: SerialDisposable = { 32 | let d: SerialDisposable = SerialDisposable() 33 | d.disposed(by: self.disposeBag) 34 | return d 35 | }() 36 | 37 | // MARK: Methods 38 | open func configure(with component: AnyComponent) { 39 | self.layout = self.component?.layout 40 | self.frame.size = self.intrinsicContentSize 41 | 42 | self.layout?.arrangement( 43 | origin: self.bounds.origin, 44 | width: self.bounds.size.width, 45 | height: self.bounds.size.height 46 | ) 47 | .makeViews(in: self) 48 | 49 | if let component = self.component?.identity.base as? Selectable { 50 | self.disposable.disposable = self.tap.rx.event.subscribe( 51 | onNext: { [weak self] (_: UITapGestureRecognizer) -> Void in 52 | guard let s = self else { return } 53 | component.onSelect(s) 54 | }, 55 | onDisposed: { [weak self] () -> Void in 56 | guard let s = self else { return } 57 | s.removeGestureRecognizer(s.tap) 58 | } 59 | ) 60 | 61 | self.addGestureRecognizer(self.tap) 62 | } 63 | } 64 | 65 | open override func sizeThatFits(_ size: CGSize) -> CGSize { 66 | guard let size = self.layout?.measurement(within: size).size else { return CGSize.zero } 67 | return size 68 | } 69 | 70 | open override var intrinsicContentSize: CGSize { 71 | return self.sizeThatFits( 72 | CGSize( 73 | width: self.bounds.width, 74 | height: CGFloat.greatestFiniteMagnitude 75 | ) 76 | ) 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /Sources/Components/Protocols/ButtonComponentType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 27.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | // sourcery: AutoEquatableComponent,AutoHashableComponent 11 | // sourcery: Component = ButtonComponent,isFrameworkComponent 12 | /// ButtonComponentType is a protocol for Components that represents a UIButton. 13 | public protocol ButtonComponentType: StaticHeightComponent { 14 | 15 | // sourcery: defaultValue = ButtonLayoutType.system 16 | /// The ButtonLayoutType which maps to UIButton.ButtonType. The default value is .system. 17 | var type: ButtonLayoutType { get set } 18 | 19 | // sourcery: defaultValue = """" 20 | /// The title displayed as text on the UIButton. The default value is an empty string: "". 21 | var title: String { get set } 22 | 23 | // sourcery: defaultValue = "UIFont.systemFont(ofSize: 15.0)" 24 | /// The title displayed as text on the UIButton. The default value is an empty string: "". 25 | var font: UIFont { get set } 26 | 27 | // sourcery: defaultValue = UIEdgeInsets.zero 28 | // sourcery: skipHashing, skipEquality 29 | /// The insets on the UIButton relative to its root UIView. This is NOT the insets on the content inside the 30 | /// UIButton. The default value is UIEdgeInsets.zero. 31 | var insets: UIEdgeInsets { get set } 32 | 33 | // sourcery: defaultValue = UIColor.clear 34 | /// The background color of the UICollectionView's contentView. The default value is UIColor.clear. 35 | var backgroundColor: UIColor { get set } 36 | 37 | // sourcery: defaultValue = ButtonLayoutDefaults.defaultAlignment 38 | // sourcery: skipHashing, skipEquality 39 | /// The alignment of the underlying ButtonLayout and SizeLayout. The default value is 40 | /// ButtonLayoutDefaults.defaultAlignment. 41 | var alignment: Alignment { get set } 42 | 43 | // sourcery: defaultValue = ButtonLayoutDefaults.defaultFlexibility 44 | // sourcery: skipHashing, skipEquality 45 | /// The flexibility of the underlying ButotnLayout and SizeLayout. The default value is 46 | /// ButtonLayoutDefaults.defaultFlexibility. 47 | var flexibility: Flexibility { get set } 48 | 49 | // sourcery: defaultValue = "{ _ in }" 50 | // sourcery: skipHashing, skipEquality 51 | /// The styling applied to the UIButton. The default value is an empty style. 52 | var configuration: (UIButton) -> Void { get set } 53 | 54 | // sourcery: defaultValue = { _ in print("Hello World \(#file)") } 55 | // sourcery: skipHashing, skipEquality 56 | /// The code executed when the UIButton is tapped. 57 | var onTap: (UIButton) -> Void { get set } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /Sources/Utilities/ComponentsController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 18.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import CoreGraphics 8 | 9 | /** 10 | ComponentsController is responsible for managing an Array of AnyComponents. It functions as the data source for models 11 | to display on a UICollectionView/UITableView. 12 | */ 13 | public struct ComponentsController { 14 | 15 | /** 16 | Initializer. 17 | - Parameters: 18 | - width: The width of the UICollectionViewCell/UITableViewCell. ComponentsController mutates the width property of every 19 | Component that is added to its Array. 20 | */ 21 | public init(width: CGFloat) { 22 | self.width = width 23 | } 24 | 25 | /** 26 | The width of the UICollectionView/UITableViewCell where the Components will be displayed. 27 | */ 28 | public let width: CGFloat 29 | 30 | /** 31 | The total height of the Components will be displayed. 32 | */ 33 | public var height: CGFloat { 34 | return self.components.reduce(into: 0.0, { (currentHeight: inout CGFloat, component: AnyComponent) -> Void in 35 | currentHeight += component.layout.measurement( 36 | within: CGSize(width: self.width, height: CGFloat.greatestFiniteMagnitude) 37 | ) 38 | .size 39 | .height 40 | }) 41 | } 42 | 43 | /** 44 | The Array of AnyComponents managed by this ComponentsController. 45 | */ 46 | public internal(set) var components: [AnyComponent] = [] 47 | 48 | /** 49 | Adds a Component to the array as an AnyComponent instance. 50 | - Parameters: 51 | - component: The Component instance to be added to the components array. 52 | */ 53 | public mutating func add(_ component: C) { 54 | self.components.append(component.asAnyComponent) 55 | } 56 | 57 | /** 58 | Adds Components of the same type to the array. 59 | - Parameters: 60 | - components: The Component instances to be added to the components array. 61 | */ 62 | public mutating func add(_ components: Components) where Components.Element == C { 63 | self.components.append(contentsOf: components.map({ $0.asAnyComponent})) 64 | } 65 | 66 | /** 67 | Adds Components of the same type to the array. Variadic version of the method. 68 | - Parameters: 69 | - components: The Component instances to be added to the components array. 70 | */ 71 | public mutating func add(_ components: Components...) { 72 | self.components.append(contentsOf: components.map({ $0.asAnyComponent})) 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /Sources/Components/ButtonComponent/ButtonComponentLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 10.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import RxSwift 9 | import UIKit 10 | 11 | /** 12 | The ButtonComponentLayout is a ComponentLayout that is a subclass of SizeLayout. 13 | Used to create, size, and arrange the subviews associated with ButtonComponent. 14 | */ 15 | open class ButtonComponentLayout: SizeLayout, ComponentLayout { 16 | 17 | /** 18 | Initializer. 19 | - Parameters: 20 | - component: The ButtonComponent whose properties define the UI characters of the subviews to be created. 21 | */ 22 | public init(component: ButtonComponent) { 23 | let size: CGSize = component.size 24 | let insets: UIEdgeInsets = component.insets 25 | let adjustedHeight: CGFloat = size.height + insets.top + insets.bottom 26 | 27 | let serialDisposable: SerialDisposable = SerialDisposable() 28 | let disposeBag: DisposeBag = DisposeBag() 29 | serialDisposable.disposed(by: disposeBag) 30 | self.disposeBag = disposeBag 31 | 32 | let buttonLayout: ButtonLayout = ButtonLayout( 33 | type: component.type, 34 | title: component.title, 35 | image: ButtonLayoutImage.size(CGSize(width: size.width, height: adjustedHeight)), 36 | font: component.font, 37 | alignment: component.alignment, 38 | flexibility: component.flexibility, 39 | config: { (view: UIButton) -> Void in 40 | component.configuration(view) 41 | serialDisposable.disposable = view.rx.controlEvent(UIControl.Event.touchUpInside) 42 | .map { () -> UIButton in 43 | return view 44 | } 45 | .bind(onNext: component.onTap) 46 | } 47 | ) 48 | 49 | let insetLayout: InsetLayout = InsetLayout( 50 | insets: component.insets, 51 | flexibility: Flexibility.inflexible, 52 | viewReuseId: "\(ButtonComponentLayout.identifier)InsetLayout", 53 | sublayout: buttonLayout 54 | ) 55 | 56 | super.init( 57 | minWidth: size.width, 58 | maxWidth: size.width, 59 | minHeight: size.height + insets.top + insets.bottom, 60 | maxHeight: size.height + insets.top + insets.bottom, 61 | alignment: component.alignment, 62 | flexibility: component.flexibility, 63 | viewReuseId: "\(ButtonComponentLayout.identifier)SizeLayout", 64 | sublayout: insetLayout, 65 | config: { (view: UIView) -> Void in 66 | view.backgroundColor = component.backgroundColor 67 | } 68 | ) 69 | } 70 | 71 | public let disposeBag: DisposeBag 72 | } 73 | -------------------------------------------------------------------------------- /Sources/Components/Protocols/ExpandableComponentType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 01.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | // sourcery: AutoEquatableComponent,AutoHashableComponent 10 | // sourcery: Component = ExpandableComponent,isFrameworkComponent 11 | /// ExpandableComponentType is a protocol for Component data structures that want to function like section headers 12 | /// with content that can be hidden / shown on tap. 13 | public protocol ExpandableComponentType: StaticHeightComponent { 14 | 15 | // sourcery: defaultValue = "EmptyContentLayout()" 16 | /// The content of the ExpandableComponentType to be displayed. Excludes the accessory UIView. 17 | var contentLayout: ExpandableContentLayout { get set } 18 | 19 | // sourcery: defaultValue = UIColor.clear 20 | /// The backgroundColor for the entire content of the ExpandableComponentType. The default value is UIColor.clear. 21 | var backgroundColor: UIColor { get set } 22 | 23 | // sourcery: defaultValue = UIEdgeInsets.zero 24 | // sourcery: skipHashing, skipEquality 25 | /// The insets for the entire content of the ExpandableComponentType including the accessory UIView. The default value is UIEdgeInsets.zero. 26 | var insets: UIEdgeInsets { get set } 27 | 28 | // sourcery: defaultValue = "UIView.self" 29 | // sourcery: skipHashing, skipEquality 30 | /// The UIView type used as the accessory UIView instance. The default value is UIView.self. 31 | var accessoryViewType: UIView.Type { get set } 32 | 33 | // sourcery: defaultValue = "CGSize(width: 12.0, height: 12.0)" 34 | /// The size of the accessory UIView instance. The default value is CGSize(width: 12.0, height: 12.0). 35 | var accessoryViewSize: CGSize { get set } 36 | 37 | // sourcery: defaultValue = "{ _ in }" 38 | // sourcery: skipHashing, skipEquality 39 | /// The configuration that will be applied accessory UIView instance. The default closure does nothing. 40 | var accessoryViewConfiguration: (UIView) -> Void { get set } 41 | 42 | // sourcery: defaultValue = "{ _ in }" 43 | // sourcery: skipHashing, skipEquality 44 | /// The configuration that will be applied to the root UIView. The default closure does nothing. 45 | var configuration: (UIView) -> Void { get set } 46 | 47 | // sourcery: defaultValue = false 48 | /// The state of the ExpandableComponentType that shows whether it is expanded or contracted. 49 | var isExpanded: Bool { get set } 50 | 51 | // sourcery: skipHashing,skipEquality 52 | // sourcery: defaultValue = "{ (_: String, _: Bool) -> Void in fatalError("This default closure must be replaced!") }" 53 | /// A reference to the function will set a new state when the ExpandableComponentType is tapped. 54 | var setExpandableState: (String, Bool) -> Void { get set } 55 | 56 | // sourcery: skipHashing,skipEquality 57 | /// The dividerLine 58 | var dividerLine: DividerLine? { get set } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /Sources/Components/TextFieldComponent/CyanicTextFieldDelegateProxy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 21.05.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | /** 10 | Serves as the UITextFieldDelegate for the UITextField created in the TextComponentLayout. 11 | */ 12 | internal final class CyanicTextFieldDelegateProxy: NSObject { 13 | 14 | // MARK: Stored Properties 15 | /** 16 | The closure executed when the textFieldShouldBeginEditing delegate method is called. 17 | */ 18 | internal var shouldBeginEditing: (UITextField) -> Bool = { _ in return true } 19 | 20 | /** 21 | The closure executed when the textFieldDidBeginEditing delegate method is called. 22 | */ 23 | internal var didBeginEditing: (UITextField) -> Void = { _ in } 24 | 25 | /** 26 | The closure executed when the textFieldShouldEndEditing delegate method is called. 27 | */ 28 | internal var shouldEndEditing: (UITextField) -> Bool = { _ in return true } 29 | 30 | /** 31 | The closure executed when the textFieldDidEndEditing delegate method is called. 32 | */ 33 | internal var didEndEditing: (UITextField) -> Void = { _ in } 34 | 35 | /** 36 | The maximum number of characters allowed on the UITextField. 37 | */ 38 | internal var maximumCharacterCount: Int = Int.max 39 | 40 | /** 41 | The closure executed when the textFieldShouldClear delegate method is called. 42 | */ 43 | internal var shouldClear: (UITextField) -> Bool = { _ in return true } 44 | 45 | /** 46 | The closure executed when the textFieldShouldReturn delegate method is called. 47 | */ 48 | internal var shouldReturn: (UITextField) -> Bool = { _ in return true } 49 | } 50 | 51 | extension CyanicTextFieldDelegateProxy: UITextFieldDelegate { 52 | 53 | internal func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { 54 | return self.shouldBeginEditing(textField) 55 | } 56 | 57 | internal func textFieldDidBeginEditing(_ textField: UITextField) { 58 | self.didBeginEditing(textField) 59 | } 60 | 61 | internal func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { 62 | return self.shouldEndEditing(textField) 63 | } 64 | 65 | internal func textFieldDidEndEditing(_ textField: UITextField) { 66 | self.didEndEditing(textField) 67 | } 68 | 69 | internal func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { 70 | let currentText: String = textField.text ?? "" 71 | guard let stringRange = Range(range, in: currentText) else { return false } 72 | let updatedText: String = currentText.replacingCharacters(in: stringRange, with: string) 73 | return updatedText.count <= self.maximumCharacterCount 74 | } 75 | 76 | internal func textFieldShouldClear(_ textField: UITextField) -> Bool { 77 | return self.shouldClear(textField) 78 | } 79 | 80 | internal func textFieldShouldReturn(_ textField: UITextField) -> Bool { 81 | return self.shouldReturn(textField) 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /Sources/Components/TextViewComponent/TextViewComponentLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 16.05.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | 9 | /** 10 | The TextViewComponentLayout is a ComponentLayout that is a subclass of SizeLayout. 11 | Used to create, size, and arrange the subviews associated with TextViewComponent. 12 | */ 13 | public final class TextViewComponentLayout: SizeLayout, ComponentLayout { 14 | 15 | /** 16 | Initializer. 17 | - Parameters: 18 | - component: The StaticTextComponent whose properties define the UI characters of the subviews to be created. 19 | */ 20 | public init(component: TextViewComponent) { 21 | 22 | let textLayout: TextViewLayout = TextViewLayout( 23 | text: Text.unattributed(component.text), 24 | font: component.font, 25 | lineFragmentPadding: 0, 26 | textContainerInset: component.textContainerInset, 27 | layoutAlignment: component.alignment, 28 | flexibility: component.flexibility, 29 | viewReuseId: "\(TextViewComponentLayout.identifier)TextView", 30 | viewClass: component.textViewType, 31 | config: { (view: UITextView) -> Void in 32 | view.backgroundColor = component.backgroundColor 33 | view.isEditable = true 34 | view.isUserInteractionEnabled = true 35 | view.isScrollEnabled = true 36 | component.configuration(view) 37 | 38 | view.delegate = component.delegate 39 | guard let delegate = component.delegate as? CyanicTextViewDelegateProxy else { return } 40 | delegate.shouldBeginEditing = component.shouldBeginEditing 41 | delegate.didBeginEditing = component.didBeginEditing 42 | delegate.shouldEndEditing = component.shouldEndEditing 43 | delegate.didEndEditing = component.didEndEditing 44 | delegate.maximumCharacterCount = component.maximumCharacterCount 45 | delegate.didChange = component.didChange 46 | delegate.didChangeSelection = component.didChangeSelection 47 | delegate.shouldInteractWithURLInCharacterRange = component.shouldInteractWithURLInCharacterRange 48 | delegate 49 | .shouldInteractWithTextAttachmentInCharacterRange = component 50 | .shouldInteractWithTextAttachmentInCharacterRange 51 | } 52 | ) 53 | 54 | let insetLayout: InsetLayout = InsetLayout( 55 | insets: component.insets, 56 | sublayout: textLayout 57 | ) 58 | 59 | super.init( 60 | minWidth: component.width, 61 | maxWidth: component.width, 62 | minHeight: component.height, 63 | maxHeight: component.height, 64 | viewReuseId: "\(TextViewComponentLayout.identifier)Size", 65 | sublayout: insetLayout, 66 | config: { 67 | $0.backgroundColor = component.backgroundColor 68 | } 69 | ) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Example/ExampleCounter/ExampleCounterVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestVC.swift 3 | // Example 4 | // 5 | // Created by Julio Miguel Alorro on 3/24/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Cyanic 11 | import RxCocoa 12 | import Kio 13 | 14 | class ExampleCounterVC: CyanicViewController { 15 | 16 | // MARK: UIViewController Lifecycle Methods 17 | override func loadView() { 18 | self.view = UIView() 19 | self.view.backgroundColor = UIColor(white: 0.85, alpha: 1.0) 20 | 21 | self.label = UILabel() 22 | self.label.textAlignment = .center 23 | self.label.text = self.count.description 24 | 25 | self.view.addSubview(self.label) 26 | self.label.translatesAutoresizingMaskIntoConstraints = false 27 | 28 | NSLayoutConstraint.activate([ 29 | self.label.heightAnchor.constraint(equalToConstant: 50.0), 30 | self.label.widthAnchor.constraint(equalToConstant: 100.0), 31 | self.label.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), 32 | self.label.centerYAnchor.constraint(equalTo: self.view.centerYAnchor) 33 | ]) 34 | 35 | } 36 | 37 | override func viewDidLoad() { 38 | super.viewDidLoad() 39 | 40 | self.kio.setUpNavigationItem { (item: UINavigationItem) -> Void in 41 | item.rightBarButtonItem = UIBarButtonItem( 42 | barButtonSystemItem: UIBarButtonItem.SystemItem.add, 43 | target: self, 44 | action: #selector(ExampleCounterVC.addButtonItemTapped) 45 | ) 46 | } 47 | 48 | // if self.presentingViewController != nil { 49 | let button: UIButton = UIButton(type: UIButton.ButtonType.system) 50 | button.setTitleColor(UIColor.black, for: UIControl.State.normal) 51 | button.setTitle("Back", for: UIControl.State.normal) 52 | button.addTarget(self, action: #selector(ExampleCounterVC.cancelButtonItemTapped), for: UIControl.Event.touchUpInside) 53 | button.frame = CGRect(origin: CGPoint(x: 100.0, y: 100.0), size: CGSize(width: 100.0, height: 44.0)) 54 | button.translatesAutoresizingMaskIntoConstraints = false 55 | self.view.addSubview(button) 56 | self.view.bringSubviewToFront(button) 57 | // } 58 | } 59 | 60 | // MARK: Stored Properties 61 | private var label: UILabel! 62 | private var count: Int = 0 63 | private let viewModel: ExampleCounterViewModel = ExampleCounterViewModel(initialState: ExampleCounterState.default) 64 | 65 | // MARK: Computed Properties 66 | override var viewModels: [AnyViewModel] { 67 | return [ 68 | self.viewModel.asAnyViewModel 69 | ] 70 | } 71 | 72 | // MARK: Methods 73 | override func invalidate() { 74 | self.label.text = self.count.description 75 | } 76 | 77 | // MARK: Target Action Methods 78 | @objc func addButtonItemTapped() { 79 | self.count += 1 80 | let count: Int = self.count 81 | self.viewModel.setState(with: { $0.changeCount = count }) 82 | } 83 | 84 | @objc func cancelButtonItemTapped() { 85 | self.presentingViewController?.dismiss(animated: true, completion: nil) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/Components/TextFieldComponent/TextFieldComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 09.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | // swiftlint:disable weak_delegate 11 | 12 | // sourcery: AutoGenerateComponent,AutoGenerateComponentExtension 13 | // sourcery: ComponentLayout = TextFieldComponentLayout 14 | /// TextFieldComponent is a Component that represents a UITextField. 15 | /// - Note: 16 | /// When configuring the UITextfield, do not assign a UITextFieldDelegate because it will be overwritten. 17 | public struct TextFieldComponent: TextFieldComponentType { 18 | 19 | // sourcery:inline:auto:TextFieldComponent.AutoGenerateComponent 20 | /** 21 | Work around Initializer because memberwise initializers are all or nothing 22 | - Parameters: 23 | - id: The unique identifier of the TextFieldComponent. 24 | */ 25 | public init(id: String) { 26 | self.id = id 27 | } 28 | 29 | public var id: String 30 | 31 | public var width: CGFloat = 0.0 32 | 33 | public var height: CGFloat = 44.0 34 | 35 | public var placeholder: String = "" 36 | 37 | // sourcery: skipHashing, skipEquality 38 | public var text: String = "" 39 | 40 | // sourcery: skipHashing, skipEquality 41 | public var insets: UIEdgeInsets = UIEdgeInsets.zero 42 | 43 | public var backgroundColor: UIColor = UIColor.clear 44 | 45 | // sourcery: skipHashing, skipEquality 46 | public var alignment: Alignment = Alignment.centerLeading 47 | 48 | // sourcery: skipHashing, skipEquality 49 | public var flexibility: Flexibility = Flexibility.flexible 50 | 51 | public var editingChangeDelay: Int = 500 52 | 53 | // sourcery: skipHashing, skipEquality 54 | public var configuration: (UITextField) -> Void = { _ in } 55 | 56 | // sourcery: skipHashing, skipEquality 57 | public var editingChanged: (UITextField) -> Void = { (_: UITextField) -> Void in print("TextField has new text") } 58 | 59 | // sourcery: skipHashing, skipEquality 60 | public var textFieldType: UITextField.Type = UITextField.self 61 | 62 | // sourcery: skipHashing, skipEquality 63 | public let delegate: UITextFieldDelegate = CyanicTextFieldDelegateProxy() 64 | 65 | // sourcery: skipHashing, skipEquality 66 | public var shouldBeginEditing: (UITextField) -> Bool = { _ in return true } 67 | 68 | // sourcery: skipHashing, skipEquality 69 | public var didBeginEditing: (UITextField) -> Void = { _ in } 70 | 71 | // sourcery: skipHashing, skipEquality 72 | public var shouldEndEditing: (UITextField) -> Bool = { _ in return true } 73 | 74 | // sourcery: skipHashing, skipEquality 75 | public var didEndEditing: (UITextField) -> Void = { _ in } 76 | 77 | public var maximumCharacterCount: Int = Int.max 78 | 79 | // sourcery: skipHashing, skipEquality 80 | public var shouldClear: (UITextField) -> Bool = { _ in return true } 81 | 82 | // sourcery: skipHashing, skipEquality 83 | public var shouldReturn: (UITextField) -> Bool = { _ in return true } 84 | 85 | // sourcery: skipHashing, skipEquality 86 | public var layout: ComponentLayout { return TextFieldComponentLayout(component: self) } 87 | 88 | public var identity: TextFieldComponent { return self } 89 | // sourcery:end 90 | } 91 | -------------------------------------------------------------------------------- /Sources/Components/TextViewComponent/TextViewComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 16.05.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | // swiftlint:disable line_length weak_delegate 11 | 12 | // sourcery: AutoGenerateComponent,AutoGenerateComponentExtension 13 | // sourcery: ComponentLayout = TextViewComponentLayout 14 | public struct TextViewComponent: TextViewComponentType { 15 | 16 | // sourcery:inline:auto:TextViewComponent.AutoGenerateComponent 17 | /** 18 | Work around Initializer because memberwise initializers are all or nothing 19 | - Parameters: 20 | - id: The unique identifier of the TextViewComponent. 21 | */ 22 | public init(id: String) { 23 | self.id = id 24 | } 25 | 26 | public var id: String 27 | 28 | public var width: CGFloat = 0.0 29 | 30 | public var height: CGFloat = 44.0 31 | 32 | // sourcery: skipHashing, skipEquality 33 | public var text: String = "" 34 | 35 | // sourcery: skipHashing, skipEquality 36 | public var font: UIFont = UIFont.systemFont(ofSize: 13.0) 37 | 38 | // sourcery: skipHashing, skipEquality 39 | public var insets: UIEdgeInsets = UIEdgeInsets.zero 40 | 41 | // sourcery: skipHashing, skipEquality 42 | public var textContainerInset: UIEdgeInsets = UIEdgeInsets.zero 43 | 44 | public var backgroundColor: UIColor = UIColor.clear 45 | 46 | // sourcery: skipHashing, skipEquality 47 | public var alignment: Alignment = Alignment.fill 48 | 49 | // sourcery: skipHashing, skipEquality 50 | public var flexibility: Flexibility = Flexibility.flexible 51 | 52 | // sourcery: skipHashing, skipEquality 53 | public var configuration: (UITextView) -> Void = { _ in } 54 | 55 | // sourcery: skipHashing, skipEquality 56 | public var textViewType: UITextView.Type = UITextView.self 57 | 58 | // sourcery: skipHashing, skipEquality 59 | public let delegate: UITextViewDelegate = CyanicTextViewDelegateProxy() 60 | 61 | // sourcery: skipHashing, skipEquality 62 | public var shouldBeginEditing: (UITextView) -> Bool = { _ in return true } 63 | 64 | // sourcery: skipHashing, skipEquality 65 | public var shouldEndEditing: (UITextView) -> Bool = { _ in return true } 66 | 67 | // sourcery: skipHashing, skipEquality 68 | public var didBeginEditing: (UITextView) -> Void = { _ in } 69 | 70 | // sourcery: skipHashing, skipEquality 71 | public var didEndEditing: (UITextView) -> Void = { _ in } 72 | 73 | // sourcery: skipHashing, skipEquality 74 | public var maximumCharacterCount: Int = Int.max 75 | 76 | // sourcery: skipHashing, skipEquality 77 | public var didChange: (UITextView) -> Void = { _ in } 78 | 79 | // sourcery: skipHashing, skipEquality 80 | public var didChangeSelection: (UITextView) -> Void = { _ in } 81 | 82 | // sourcery: skipHashing, skipEquality 83 | public var shouldInteractWithURLInCharacterRange: (UITextView, URL, NSRange, UITextItemInteraction) -> Bool = { _, _, _, _ in return true } 84 | 85 | // sourcery: skipHashing, skipEquality 86 | public var shouldInteractWithTextAttachmentInCharacterRange: (UITextView, NSTextAttachment, NSRange, UITextItemInteraction) -> Bool = { _, _, _, _ in return true } 87 | 88 | // sourcery: skipHashing, skipEquality 89 | public var layout: ComponentLayout { return TextViewComponentLayout(component: self) } 90 | 91 | public var identity: TextViewComponent { return self } 92 | // sourcery:end 93 | } 94 | -------------------------------------------------------------------------------- /Sources/Components/TextFieldComponent/TextFieldComponentLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 09.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | import RxSwift 10 | import RxCocoa 11 | 12 | open class TextFieldComponentLayout: InsetLayout, ComponentLayout { 13 | 14 | public init(component: TextFieldComponent) { // swiftlint:disable:this function_body_length 15 | let insets: UIEdgeInsets = component.insets 16 | 17 | let serialDisposable: SerialDisposable = SerialDisposable() 18 | let disposeBag: DisposeBag = DisposeBag() 19 | 20 | serialDisposable.disposed(by: disposeBag) 21 | 22 | let size: CGSize = component.size 23 | 24 | let textFieldLayout: SizeLayout = SizeLayout( 25 | minWidth: size.width, 26 | maxWidth: size.width, 27 | minHeight: size.height, 28 | maxHeight: size.height, 29 | alignment: component.alignment, 30 | flexibility: component.flexibility, 31 | viewReuseId: "\(TextFieldComponentLayout.identifier)SizeLayout", 32 | viewClass: component.textFieldType, 33 | config: { (view: UIView) -> Void in 34 | guard let view = view as? UITextField else { return } 35 | component.configuration(view) 36 | 37 | view.text = component.text 38 | view.placeholder = component.placeholder 39 | 40 | let disposables: [Disposable] = [ 41 | view.rx.controlEvent([UIControl.Event.editingChanged]) 42 | .map({ () -> UITextField in 43 | return view 44 | }) 45 | .debounce(RxTimeInterval.milliseconds(component.editingChangeDelay), scheduler: MainScheduler.instance) 46 | .bind( 47 | onNext: { (view: UITextField) -> Void in 48 | component.editingChanged(view) 49 | } 50 | ) 51 | ] 52 | 53 | let compositeDisposable: CompositeDisposable = CompositeDisposable(disposables: disposables) 54 | serialDisposable.disposable = compositeDisposable 55 | view.delegate = component.delegate 56 | 57 | guard let delegate = component.delegate as? CyanicTextFieldDelegateProxy else { return } 58 | delegate.shouldBeginEditing = component.shouldBeginEditing 59 | delegate.didBeginEditing = component.didBeginEditing 60 | delegate.shouldEndEditing = component.shouldEndEditing 61 | delegate.didEndEditing = component.didEndEditing 62 | delegate.maximumCharacterCount = component.maximumCharacterCount 63 | delegate.shouldClear = component.shouldClear 64 | delegate.shouldReturn = component.shouldReturn 65 | } 66 | ) 67 | 68 | self.disposeBag = disposeBag 69 | super.init( 70 | insets: insets, 71 | alignment: component.alignment, 72 | flexibility: component.flexibility, 73 | viewReuseId: "\(TextFieldComponentLayout.identifier)InsetLayout", 74 | sublayout: textFieldLayout, 75 | config: { (view: UIView) -> Void in 76 | view.backgroundColor = component.backgroundColor 77 | } 78 | ) 79 | } 80 | 81 | public let disposeBag: DisposeBag 82 | 83 | } 84 | -------------------------------------------------------------------------------- /Sources/CollectionView/CollectionComponentViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 12.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | /** 10 | CollectionComponentViewController is a subclass of ComponentViewController. It serves as the base class for the 11 | SingleSectionCollectionComponentViewController and MultiSectionCollectionComponentViewController, therefore it contains 12 | the logic and implementations shared between the two subclasses. 13 | */ 14 | open class CollectionComponentViewController: ComponentViewController, UICollectionViewDelegateFlowLayout { 15 | 16 | // MARK: UIViewController Lifecycle Methods 17 | open override func viewDidLoad() { 18 | super.viewDidLoad() 19 | self.collectionView.register(CollectionComponentCell.self, forCellWithReuseIdentifier: CollectionComponentCell.identifier) 20 | 21 | // Set up as the UICollectionView's UICollectionViewDelegateFlowLayout, 22 | // UICollectionViewDelegate, and UIScrollViewDelegate 23 | self.collectionView.delegate = self 24 | } 25 | 26 | open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { 27 | super.viewWillTransition(to: size, with: coordinator) 28 | guard self._listView != nil else { return } 29 | self.collectionView.collectionViewLayout.invalidateLayout() 30 | } 31 | 32 | // MARK: Computed Properties 33 | /** 34 | The UICollectionView instance managed by this CollectionComponentViewController instance. 35 | */ 36 | public var collectionView: UICollectionView { 37 | return self._listView as! UICollectionView // swiftlint:disable:this force_cast 38 | } 39 | 40 | internal typealias CombinedState = (CGSize, [Any]) 41 | 42 | // MARK: Methods 43 | /** 44 | Creates a UICollectionView with a UICollectionViewLayout instantiated from the createUICollectionViewLayout method. 45 | This method is called in the ComponentViewController's loadView method. 46 | - Returns: 47 | - UICollectionView instance typed as a UIView. 48 | */ 49 | open override func setUpListView() -> UIView { 50 | return UICollectionView( 51 | frame: CGRect.zero, 52 | collectionViewLayout: self.createUICollectionViewLayout() 53 | ) 54 | } 55 | 56 | /** 57 | Creates the UICollectionViewLayout to be used by the UICollectionView managed by this CollectionComponentViewController. 58 | The default implementation creates a UICollectionViewFlowLayout with a minimumLineSpacing and minimumInteritemSpacing of 59 | 0.0. 60 | - Returns: 61 | A UICollectionViewLayout instance. 62 | */ 63 | open func createUICollectionViewLayout() -> UICollectionViewLayout { 64 | let layout: UICollectionViewFlowLayout = UICollectionViewFlowLayout() 65 | layout.minimumLineSpacing = 0.0 66 | layout.minimumInteritemSpacing = 0.0 67 | return layout 68 | } 69 | 70 | // MARK: UICollectionViewDelegateFlowLayout Methods 71 | open func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 72 | collectionView.deselectItem(at: indexPath, animated: false) 73 | guard let component: AnyComponent = self.component(at: indexPath) else { return } 74 | guard let selectable = component.identity.base as? Selectable else { return } 75 | guard let cell = collectionView.cellForItem(at: indexPath) else { return } 76 | selectable.onSelect(cell.contentView) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Cyanic.xcodeproj/xcshareddata/xcschemes/Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Sources/TableView/TableComponentSectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 14.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import Foundation 8 | import LayoutKit 9 | import RxSwift 10 | import UIKit 11 | 12 | /** 13 | TableComponentSectionView serves as the root UIView for any section UIView for UITableViews. 14 | */ 15 | public final class TableComponentSectionView: UIView { 16 | 17 | // MARK: Class Properties 18 | /** 19 | The String identifier used by the TableComponentSectionView. 20 | */ 21 | public class var identifier: String { 22 | return String(describing: Mirror(reflecting: self).subjectType) 23 | } 24 | 25 | public override init(frame: CGRect) { 26 | super.init(frame: frame) 27 | self.serialDisposable.disposed(by: self.disposeBag) 28 | } 29 | 30 | public required init?(coder aDecoder: NSCoder) { 31 | fatalError("init(coder:) has not been implemented") 32 | } 33 | 34 | // MARK: Stored Properties 35 | /** 36 | The current ComponentLayout instance that creates and arranges the subviews in this TableComponentSectionView. 37 | */ 38 | private var layout: ComponentLayout? 39 | 40 | /** 41 | The UITapGestureRecognizer instance that handles user tap gestures, if there is one. 42 | */ 43 | private var tap: UITapGestureRecognizer? 44 | 45 | /** 46 | The DisposeBag that manages Rx-related subscriptions. 47 | */ 48 | private let disposeBag: DisposeBag = DisposeBag() 49 | 50 | /** 51 | The SerialDisposable that ensures there is only one subscription at a time for the UITapGestureRecognizer. 52 | */ 53 | private let serialDisposable: SerialDisposable = SerialDisposable() 54 | 55 | // MARK: Overridden Properties 56 | public override final var intrinsicContentSize: CGSize { 57 | return self.sizeThatFits( 58 | CGSize( 59 | width: Constants.screenWidth, 60 | height: CGFloat.greatestFiniteMagnitude 61 | ) 62 | ) 63 | } 64 | 65 | // MARK: Overridden Methods 66 | public override final func sizeThatFits(_ size: CGSize) -> CGSize { 67 | guard let size = self.layout?.measurement(within: size).size else { return CGSize.zero } 68 | return size 69 | } 70 | 71 | /** 72 | Reads the layout from the AnyComponent instance to create the subviews in this TableComponentSectionView instance. T 73 | his also sets the frame.size equal to its intrinsicContentSize and calls setNeedsLayout. 74 | - Parameters: 75 | - component: The AnyComponent instance that represents this TableComponentSectionView. 76 | */ 77 | public func configure(with component: AnyComponent) { 78 | self.layout = component.layout 79 | self.frame.size = self.intrinsicContentSize 80 | 81 | if let selectable = component.identity.base as? Selectable { 82 | let tap: UITapGestureRecognizer = UITapGestureRecognizer() 83 | 84 | let disposable: Disposable = tap.rx.event.bind(onNext: { (_: UITapGestureRecognizer) -> Void in 85 | selectable.onSelect(self) 86 | }) 87 | 88 | self.addGestureRecognizer(tap) 89 | self.serialDisposable.disposable = disposable 90 | self.tap = tap 91 | } 92 | 93 | self.layout?.arrangement( 94 | origin: self.bounds.origin, 95 | width: self.bounds.size.width, 96 | height: self.bounds.size.height 97 | ) 98 | .makeViews(in: self) 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /Templates/AutoGenerateComponentExtensions.swifttemplate: -------------------------------------------------------------------------------- 1 | <% 2 | func lowerCamelCaseName(for type: Type) -> String { 3 | let name: String = type.name 4 | return name.prefix(1).lowercased() + name.dropFirst() 5 | } 6 | -%> 7 | // sourcery:file:DataSourceControllers+<%= argument["project"] as? String ?? "Cyanic" %>.swift 8 | <%_ if let isFramework = argument["isFramework"] as? Bool, isFramework {-%> 9 | <%_ } else { -%> 10 | import Cyanic 11 | 12 | <%_ } -%> 13 | // swiftlint:disable all 14 | public extension ComponentsController { 15 | <%_ for type in types.all where type.annotations["AutoGenerateComponentExtension"] != nil {-%> 16 | <%_ let functionName: String = lowerCamelCaseName(for: type) -%> 17 | 18 | /** 19 | Generates a <%= type.name %> instance and configures its properties with the given closure. You must provide a 20 | unique id in the configuration block, otherwise it will force a fatalError. 21 | - Parameters: 22 | - configuration: The closure that mutates the mutable <%= type.name %>. 23 | - mutableComponent: The <%= type.name %> instance to be mutated/configured. 24 | - Returns: 25 | <%= type.name %> 26 | */ 27 | @discardableResult 28 | mutating func <%= functionName %>(configuration: (_ mutableComponent: inout <%= type.name %>) -> Void) -> <%= type.name %> { 29 | var mutableComponent: <%= type.name %> = <%= type.name %>(id: Constants.invalidID) 30 | configuration(&mutableComponent) 31 | mutableComponent.width = self.width 32 | guard ComponentStateValidator.hasValidIdentifier(mutableComponent) 33 | else { fatalError("You must have a unique identifier for this component") } 34 | <%_ if type.annotations["RequiredVariables"] != nil { -%> 35 | guard ComponentStateValidator.validate<%= type.name %>(mutableComponent) 36 | else { fatalError("You did not configure all required variables in this component") } 37 | <%_ } -%> 38 | self.add(mutableComponent) 39 | return mutableComponent 40 | } 41 | <%_ } -%> 42 | } 43 | 44 | public extension SectionController { 45 | <%_ for type in types.all where type.annotations["AutoGenerateComponentExtension"] != nil {-%> 46 | <%_ let functionName: String = lowerCamelCaseName(for: type) -%> 47 | 48 | /** 49 | Generates a <%= type.name %> instance and configures its properties with the given closure. You must provide a 50 | unique id in the configuration block, otherwise it will force a fatalError. 51 | - Parameters: 52 | - configuration: The closure that mutates the mutable <%= type.name %>. 53 | - mutableComponent: The <%= type.name %> instance to be mutated/configured. 54 | - Returns: 55 | <%= type.name %> 56 | */ 57 | @discardableResult 58 | mutating func <%= functionName %>(for supplementaryView: SectionController.SupplementaryView, configuration: (_ mutableComponent: inout <%= type.name %>) -> Void) -> <%= type.name %> { 59 | var mutableComponent: <%= type.name %> = <%= type.name %>(id: Constants.invalidID) 60 | configuration(&mutableComponent) 61 | mutableComponent.width = self.width 62 | guard ComponentStateValidator.hasValidIdentifier(mutableComponent) 63 | else { fatalError("You must have a unique identifier for this component") } 64 | <%_ if type.annotations["RequiredVariables"] != nil { -%> 65 | guard ComponentStateValidator.validate<%= type.name %>(mutableComponent) 66 | else { fatalError("You did not configure all required variables in this component") } 67 | <%_ } -%> 68 | 69 | switch supplementaryView { 70 | case .header: 71 | self.headerComponent = mutableComponent.asAnyComponent 72 | case .footer: 73 | self.footerComponent = mutableComponent.asAnyComponent 74 | } 75 | 76 | return mutableComponent 77 | } 78 | <%_ } -%> 79 | } 80 | // swiftlint:enable all 81 | // sourcery:end 82 | -------------------------------------------------------------------------------- /Sources/Sourcery/AutoHashableComponent+Cyanic.swift: -------------------------------------------------------------------------------- 1 | // Generated using Sourcery 0.17.0 — https://github.com/krzysztofzablocki/Sourcery 2 | // DO NOT EDIT 3 | 4 | // swiftlint:disable all 5 | // MARK: - AutoHashableComponent 6 | // MARK: - ButtonComponent AutoHashableComponent 7 | extension ButtonComponent: Hashable { 8 | public func hash(into hasher: inout Hasher) { 9 | self.type.hash(into: &hasher) 10 | self.title.hash(into: &hasher) 11 | self.font.hash(into: &hasher) 12 | self.backgroundColor.hash(into: &hasher) 13 | self.id.hash(into: &hasher) 14 | self.width.hash(into: &hasher) 15 | self.height.hash(into: &hasher) 16 | } 17 | } 18 | // MARK: - ChildVCComponent AutoHashableComponent 19 | extension ChildVCComponent: Hashable { 20 | public func hash(into hasher: inout Hasher) { 21 | self.name.hash(into: &hasher) 22 | self.description.hash(into: &hasher) 23 | self.id.hash(into: &hasher) 24 | self.width.hash(into: &hasher) 25 | self.height.hash(into: &hasher) 26 | } 27 | } 28 | // MARK: - ExpandableComponent AutoHashableComponent 29 | extension ExpandableComponent: Hashable { 30 | public func hash(into hasher: inout Hasher) { 31 | self.contentLayout.hash(into: &hasher) 32 | self.backgroundColor.hash(into: &hasher) 33 | self.accessoryViewSize.hash(into: &hasher) 34 | self.isExpanded.hash(into: &hasher) 35 | self.id.hash(into: &hasher) 36 | self.width.hash(into: &hasher) 37 | self.height.hash(into: &hasher) 38 | } 39 | } 40 | // MARK: - SizedComponent AutoHashableComponent 41 | extension SizedComponent: Hashable { 42 | public func hash(into hasher: inout Hasher) { 43 | self.backgroundColor.hash(into: &hasher) 44 | self.id.hash(into: &hasher) 45 | self.width.hash(into: &hasher) 46 | self.height.hash(into: &hasher) 47 | } 48 | } 49 | // MARK: - StaticLabelComponent AutoHashableComponent 50 | extension StaticLabelComponent: Hashable { 51 | public func hash(into hasher: inout Hasher) { 52 | self.text.hash(into: &hasher) 53 | self.font.hash(into: &hasher) 54 | self.backgroundColor.hash(into: &hasher) 55 | self.numberOfLines.hash(into: &hasher) 56 | self.lineBreakMode.hash(into: &hasher) 57 | self.id.hash(into: &hasher) 58 | self.width.hash(into: &hasher) 59 | } 60 | } 61 | // MARK: - StaticSpacingComponent AutoHashableComponent 62 | extension StaticSpacingComponent: Hashable { 63 | public func hash(into hasher: inout Hasher) { 64 | self.backgroundColor.hash(into: &hasher) 65 | self.id.hash(into: &hasher) 66 | self.width.hash(into: &hasher) 67 | self.height.hash(into: &hasher) 68 | } 69 | } 70 | // MARK: - StaticTextComponent AutoHashableComponent 71 | extension StaticTextComponent: Hashable { 72 | public func hash(into hasher: inout Hasher) { 73 | self.text.hash(into: &hasher) 74 | self.font.hash(into: &hasher) 75 | self.backgroundColor.hash(into: &hasher) 76 | self.lineFragmentPadding.hash(into: &hasher) 77 | self.id.hash(into: &hasher) 78 | self.width.hash(into: &hasher) 79 | } 80 | } 81 | // MARK: - TextFieldComponent AutoHashableComponent 82 | extension TextFieldComponent: Hashable { 83 | public func hash(into hasher: inout Hasher) { 84 | self.placeholder.hash(into: &hasher) 85 | self.backgroundColor.hash(into: &hasher) 86 | self.editingChangeDelay.hash(into: &hasher) 87 | self.maximumCharacterCount.hash(into: &hasher) 88 | self.id.hash(into: &hasher) 89 | self.width.hash(into: &hasher) 90 | self.height.hash(into: &hasher) 91 | } 92 | } 93 | // MARK: - TextViewComponent AutoHashableComponent 94 | extension TextViewComponent: Hashable { 95 | public func hash(into hasher: inout Hasher) { 96 | self.backgroundColor.hash(into: &hasher) 97 | self.id.hash(into: &hasher) 98 | self.width.hash(into: &hasher) 99 | self.height.hash(into: &hasher) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/CollectionView/ComponentSupplementaryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 12.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import Foundation 8 | import LayoutKit 9 | import RxSwift 10 | import UIKit 11 | 12 | /** 13 | ComponentSupplementaryView is a UICollectionReusableView subclass that serves as the root UIView for the UI 14 | elements generated by its Layout. 15 | */ 16 | public final class ComponentSupplementaryView: UICollectionReusableView { 17 | 18 | // MARK: Class Properties 19 | /** 20 | The String identifier used by the ComponentSupplementaryView to register to a UICollectionView instance. 21 | */ 22 | public class var identifier: String { 23 | return String(describing: Mirror(reflecting: self).subjectType) 24 | } 25 | 26 | public override init(frame: CGRect) { 27 | super.init(frame: frame) 28 | self.serialDisposable.disposed(by: self.disposeBag) 29 | } 30 | 31 | public required init?(coder aDecoder: NSCoder) { 32 | fatalError("init(coder:) has not been implemented") 33 | } 34 | 35 | // MARK: Stored Properties 36 | /** 37 | The current ComponentLayout instance that created and arranged the subviews in this ComponentSupplementaryView. 38 | */ 39 | private var layout: ComponentLayout? 40 | 41 | /** 42 | The UITapGestureRecognizer instance that handles user tap gestures, if there is one. 43 | */ 44 | private var tap: UITapGestureRecognizer? 45 | 46 | /** 47 | The DisposeBag that manages Rx-related subscriptions. 48 | */ 49 | private let disposeBag: DisposeBag = DisposeBag() 50 | 51 | /** 52 | The SerialDisposable that ensures there is only one subscription at a time for the UITapGestureRecognizer. 53 | */ 54 | private let serialDisposable: SerialDisposable = SerialDisposable() 55 | 56 | // MARK: Overridden Properties 57 | public override final var intrinsicContentSize: CGSize { 58 | return self.sizeThatFits( 59 | CGSize( 60 | width: Constants.screenWidth, 61 | height: CGFloat.greatestFiniteMagnitude 62 | ) 63 | ) 64 | } 65 | 66 | // MARK: Overridden Methods 67 | public override func prepareForReuse() { 68 | super.prepareForReuse() 69 | self.subviews.forEach { $0.removeFromSuperview() } 70 | self.layout = nil 71 | 72 | if let tap = self.tap { 73 | self.removeGestureRecognizer(tap) 74 | } 75 | 76 | self.serialDisposable.disposable.dispose() 77 | self.tap = nil 78 | } 79 | 80 | public override final func sizeThatFits(_ size: CGSize) -> CGSize { 81 | guard let size = self.layout?.measurement(within: size).size else { return CGSize.zero } 82 | return size 83 | } 84 | 85 | /** 86 | Reads the layout from the AnyComponent instance to create the subviews in this ComponentSupplementaryView instance. 87 | This also sets its frame.size equal to its intrinsicContentSize and calls setNeedsLayout. 88 | - Parameters: 89 | - component: The AnyComponent instance that represents this ComponentSupplementaryView. 90 | */ 91 | public func configure(with component: AnyComponent) { 92 | self.layout = component.layout 93 | self.frame.size = self.intrinsicContentSize 94 | 95 | if let selectable = component.identity.base as? Selectable { 96 | let tap: UITapGestureRecognizer = UITapGestureRecognizer() 97 | 98 | let disposable: Disposable = tap.rx.event.bind(onNext: { (_: UITapGestureRecognizer) -> Void in 99 | selectable.onSelect(self) 100 | }) 101 | 102 | self.addGestureRecognizer(tap) 103 | self.serialDisposable.disposable = disposable 104 | self.tap = tap 105 | } 106 | 107 | self.layout?.arrangement( 108 | origin: self.bounds.origin, 109 | width: self.bounds.size.width, 110 | height: self.bounds.size.height 111 | ) 112 | .makeViews(in: self) 113 | } 114 | 115 | } 116 | -------------------------------------------------------------------------------- /Sources/Components/TextViewComponent/CyanicTextViewDelegateProxy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 22.05.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import UIKit 8 | 9 | internal final class CyanicTextViewDelegateProxy: NSObject { 10 | 11 | // Stored Properties 12 | /** 13 | The closure executed when the textViewShouldBeginEditing delegate method is called. 14 | */ 15 | internal var shouldBeginEditing: (UITextView) -> Bool = { _ in return true } 16 | 17 | /** 18 | The closure executed when the textViewShouldEndEditing delegate method is called. 19 | */ 20 | internal var shouldEndEditing: (UITextView) -> Bool = { _ in return true } 21 | 22 | /** 23 | The closure executed when the textViewDidBeginEditing delegate method is called. 24 | */ 25 | internal var didBeginEditing: (UITextView) -> Void = { _ in } 26 | 27 | /** 28 | The closure executed when the textViewDidEndEditing delegate method is called. 29 | */ 30 | internal var didEndEditing: (UITextView) -> Void = { _ in } 31 | 32 | /** 33 | The maximum number of characters allowed on the UITextView. 34 | */ 35 | internal var maximumCharacterCount: Int = Int.max 36 | 37 | /** 38 | The closure executed when the textViewDidChange delegate method is called. 39 | */ 40 | internal var didChange: (UITextView) -> Void = { _ in } 41 | 42 | /** 43 | The closure executed when the textViewDidChangeSelection delegate method is called. 44 | */ 45 | internal var didChangeSelection: (UITextView) -> Void = { _ in } 46 | 47 | /** 48 | The closure executed when the textView:shouldInteractWithURL:characterRange:interaction delegate method is called. 49 | */ 50 | internal var shouldInteractWithURLInCharacterRange: (UITextView, URL, NSRange, UITextItemInteraction) -> Bool = { _, _, _, _ in return true } // swiftlint:disable:this line_length 51 | 52 | /** 53 | The closure executed when the textView:shouldInteractWithTextAttachement:characterRange:interaction delegate 54 | method is called. 55 | */ 56 | internal var shouldInteractWithTextAttachmentInCharacterRange: (UITextView, NSTextAttachment, NSRange, UITextItemInteraction) -> Bool = { _, _, _, _ in return true } // swiftlint:disable:this line_length 57 | } 58 | 59 | extension CyanicTextViewDelegateProxy: UITextViewDelegate { 60 | internal func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { 61 | return self.shouldBeginEditing(textView) 62 | } 63 | 64 | internal func textViewShouldEndEditing(_ textView: UITextView) -> Bool { 65 | return self.shouldEndEditing(textView) 66 | } 67 | 68 | internal func textViewDidBeginEditing(_ textView: UITextView) { 69 | self.didBeginEditing(textView) 70 | } 71 | 72 | internal func textViewDidEndEditing(_ textView: UITextView) { 73 | self.didEndEditing(textView) 74 | } 75 | 76 | internal func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { 77 | let currentText: String = textView.text ?? "" 78 | guard let stringRange = Range(range, in: currentText) else { return false } 79 | let changedText: String = currentText.replacingCharacters(in: stringRange, with: text) 80 | return changedText.count <= self.maximumCharacterCount 81 | } 82 | 83 | internal func textViewDidChange(_ textView: UITextView) { 84 | self.didChange(textView) 85 | } 86 | 87 | internal func textViewDidChangeSelection(_ textView: UITextView) { 88 | self.didChangeSelection(textView) 89 | } 90 | 91 | internal func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { 92 | return self.shouldInteractWithURLInCharacterRange(textView, URL, characterRange, interaction) 93 | } 94 | 95 | internal func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { 96 | return self.shouldInteractWithTextAttachmentInCharacterRange(textView, textAttachment, characterRange, interaction) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Cyanic.xcodeproj/xcshareddata/xcschemes/Cyanic.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 49 | 50 | 51 | 52 | 53 | 54 | 64 | 65 | 71 | 72 | 73 | 74 | 78 | 79 | 80 | 81 | 87 | 88 | 94 | 95 | 96 | 97 | 99 | 100 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /Sources/Utilities/SectionController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 12.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import CoreGraphics 8 | import RxDataSources 9 | 10 | /** 11 | SectionController represents a section of a UICollectionView/UITableView. It consists of two main properties: 12 | 13 | * The Component that represents the supplementary view (in a UICollectionView) or a header view (in a UITableView) to 14 | be displayed in that section. 15 | * The ComponentsController that represents the items/rows to be displayed in that section. 16 | 17 | It is essentially the data source for a section in a UICollectionView/UITableView. 18 | */ 19 | public struct SectionController: AnimatableSectionModelType { 20 | 21 | // MARK: Initializer 22 | /** 23 | Initializer. 24 | - Parameters: 25 | - width: The width of the UICollectionView/UITableView. SectionController mutates the width property of the 26 | headerComponent and initializes its ComponentsController instance with it. 27 | */ 28 | public init(width: CGFloat) { 29 | self.width = width 30 | self.componentsController = ComponentsController(width: width) 31 | } 32 | 33 | public init(original: SectionController, items: [AnyComponent]) { 34 | self = original 35 | self.componentsController.components = items 36 | } 37 | 38 | // MARK: Stored Properties 39 | /** 40 | The width of the UICollectionView/UITableView where the components will be displayed. 41 | */ 42 | public let width: CGFloat 43 | 44 | /** 45 | The Component representing the header view for the section in the UICollectionView/UITableView. 46 | */ 47 | public var headerComponent: AnyComponent? 48 | 49 | /** 50 | The Component representing the footer view for the section in the UICollectionView/UITableView. 51 | */ 52 | public var footerComponent: AnyComponent? 53 | 54 | /** 55 | The components for this section of the UICollectionView/UITableView. 56 | */ 57 | public private(set) var componentsController: ComponentsController 58 | 59 | // MARK: Computed Properties 60 | public var identity: Int { 61 | var hasher: Hasher = Hasher() 62 | hasher.combine(self.headerComponent) 63 | hasher.combine(self.footerComponent) 64 | return hasher.finalize() 65 | } 66 | 67 | public var items: [AnyComponent] { 68 | return self.componentsController.components 69 | } 70 | 71 | /** 72 | The height of all the Components managed by this SectionController. 73 | */ 74 | public var height: CGFloat { 75 | let componentsControllerHeight: CGFloat = self.componentsController.height 76 | let headerHeight: CGFloat = self.headerComponent? 77 | .layout 78 | .measurement(within: CGSize(width: self.width, height: CGFloat.greatestFiniteMagnitude)) 79 | .size 80 | .height ?? 0.0 81 | let footerHeight: CGFloat = self.footerComponent? 82 | .layout 83 | .measurement(within: CGSize(width: self.width, height: CGFloat.greatestFiniteMagnitude)) 84 | .size 85 | .height ?? 0.0 86 | return componentsControllerHeight + headerHeight + footerHeight 87 | } 88 | 89 | /** 90 | This method mutates the ComponentsController of this SectionController. Use this method to configure the 91 | ComponentController. 92 | - Parameters: 93 | - configuration: The closure contains the configuration logic to mutate the ComponentsController 94 | - componentsController: This SectionController's ComponentsController instance. 95 | */ 96 | public mutating func buildComponents(_ configuration: (_ componentsController: inout ComponentsController) -> Void) { 97 | configuration(&self.componentsController) 98 | } 99 | 100 | } 101 | 102 | public extension SectionController { 103 | 104 | /** 105 | Represents the two options when adding a Component to a SectionContoller 106 | */ 107 | enum SupplementaryView { 108 | /** 109 | Adds the Component as a header in the UICollectionView/UITableView 110 | */ 111 | case header 112 | 113 | /** 114 | Adds the Component as a footer in the UICollectionView/UITableView 115 | */ 116 | case footer 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /Sources/Classes/CyanicViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 21.03.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import Foundation 8 | import RxCocoa 9 | import RxSwift 10 | import UIKit 11 | import os 12 | 13 | internal let CyanicViewControllerLog: OSLog = OSLog(subsystem: Constants.bundleIdentifier, category: "CyanicViewController") 14 | 15 | /** 16 | CyanicViewController is a UIViewController subclass that can listen to State changes to its ViewModels. Whenever the 17 | State of at least one of its ViewModels changes, its invalidate method is called. 18 | */ 19 | open class CyanicViewController: UIViewController, StateObservableBuilder { 20 | 21 | deinit { 22 | logDeallocation(of: self, log: CyanicViewControllerLog) 23 | } 24 | 25 | open override func viewDidLoad() { 26 | super.viewDidLoad() 27 | self.setUpObservables(with: self.viewModels) 28 | } 29 | 30 | // MARK: Stored Properties 31 | /** 32 | The serial scheduler where the ViewModel's state changes are observed on and mapped to the _components 33 | */ 34 | internal let scheduler: SerialDispatchQueueScheduler = SerialDispatchQueueScheduler( 35 | qos: DispatchQoS.userInitiated, 36 | internalSerialQueueName: "\(UUID().uuidString)" 37 | ) 38 | 39 | /** 40 | The combined state of the ViewModels as a BehviorRelay for debugging purposes. 41 | */ 42 | internal let state: BehaviorRelay<[Any]> = BehaviorRelay<[Any]>(value: [()]) 43 | 44 | /** 45 | DisposeBag for Rx-related subscriptions. 46 | */ 47 | public let disposeBag: DisposeBag = DisposeBag() 48 | 49 | // MARK: Computed Properties 50 | /** 51 | Limits the frequency of state updates. 52 | */ 53 | open var throttleType: ThrottleType { return ThrottleType.none } 54 | 55 | /** 56 | The current state of the ViewModels from the state BehaviorRelay. 57 | */ 58 | public var currentState: Any { return self.state.value } 59 | 60 | /** 61 | The ViewModels whose State is observed by this CyanicViewController. 62 | */ 63 | open var viewModels: [AnyViewModel] { return [] } 64 | 65 | internal typealias CombinedState = [Any] 66 | 67 | // MARK: Methods 68 | /** 69 | Creates an Observables based on ThrottleType and binds it to the invalidate method. 70 | 71 | It creates a new Observables based on the ViewModels' States and CyanicViewController ThrottleType and 72 | binds it to the invalidate method so any new State change calls the invalidate method. 73 | 74 | - Parameters: 75 | - viewModels: The ViewModels whose States will be observed. 76 | */ 77 | @discardableResult 78 | internal func setUpObservables(with viewModels: [AnyViewModel]) -> Observable<[Any]> { 79 | guard !viewModels.isEmpty else { return Observable<[Any]>.empty() } 80 | let combinedStatesObservables: Observable<[Any]> = viewModels 81 | .combineStateObservables() 82 | 83 | var throttledStateObservable: Observable<[Any]> = self.setUpThrottleType( 84 | on: combinedStatesObservables, 85 | throttleType: self.throttleType, 86 | scheduler: self.scheduler 87 | ) 88 | .observeOn(self.scheduler) 89 | .subscribeOn(self.scheduler) 90 | 91 | if viewModels.contains(where: { $0.isDebugMode }) { 92 | throttledStateObservable = throttledStateObservable 93 | .debug("\(type(of: self))", trimOutput: false) 94 | } 95 | 96 | throttledStateObservable = throttledStateObservable.share() 97 | 98 | throttledStateObservable 99 | .observeOn(MainScheduler.instance) 100 | .bind( 101 | onNext: { [weak self] (_: [Any]) -> Void in 102 | self?.invalidate() 103 | } 104 | ) 105 | .disposed(by: self.disposeBag) 106 | 107 | throttledStateObservable 108 | .bind(to: self.state) 109 | .disposed(by: self.disposeBag) 110 | 111 | return throttledStateObservable 112 | } 113 | 114 | /** 115 | When the State of the ViewModel changes, invalidate is called, therefore, you should place logic here that 116 | should react to changes in state. This method is run on the main thread asynchronously. 117 | 118 | When overriding, no need to call super because the default implementation does nothing. 119 | */ 120 | open func invalidate() {} 121 | 122 | } 123 | -------------------------------------------------------------------------------- /Example/ExampleList/ExampleListState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleState.swift 3 | // Example 4 | // 5 | // Created by Julio Miguel Alorro on 3/27/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import Cyanic 10 | import UIKit 11 | 12 | public struct ExampleListState: ExpandableState { 13 | 14 | // MARK: Enums 15 | public enum Expandable: String, CaseIterable { 16 | case first = "First Expandable" 17 | case second = "Second Expandable" 18 | } 19 | 20 | // MARK: Static Properties 21 | public static var `default`: ExampleListState { 22 | return ExampleListState( 23 | hasTextInTextField: true, 24 | expandableDict: ExampleListState.Expandable.allCases.map { $0.rawValue } 25 | .reduce(into: [String: Bool](), { (current, element) -> Void in 26 | current[element] = false 27 | } 28 | ), 29 | strings: [ 30 | """ 31 | Bacon ipsum dolor amet short ribs jerky spare ribs jowl, ham hock t-bone turkey capicola pork tenderloin. Rump t-bone ground round short loin ribeye alcatra pork chop spare ribs pancetta sausage chuck. Turducken pork sausage landjaeger t-bone. Kevin ground round tail ribeye pig drumstick alcatra bacon sausage. 32 | """, 33 | """ 34 | Brisket shank pork loin filet mignon, strip steak landjaeger bacon. Fatback swine bresaola frankfurter sausage, bacon venison jowl salami pork loin beef ribs chuck. Filet mignon corned beef pig frankfurter short loin cow pastrami cupim ham. Turducken pancetta kevin salami, shank boudin pastrami meatball flank filet mignon kielbasa spare ribs. 35 | 36 | Doner ham pancetta sausage beef ribs flank tail filet mignon. Turducken leberkas jerky sirloin, tongue doner shank pastrami cupim. Alcatra pork loin prosciutto brisket meatloaf, beef ribs cow pork belly burgdoggen. Corned beef tail pork belly short loin chuck drumstick. 37 | """, 38 | """ 39 | Doner ham pancetta sausage beef ribs flank tail filet mignon. Turducken leberkas jerky sirloin, tongue doner shank pastrami cupim. Alcatra pork loin prosciutto brisket meatloaf, beef ribs cow pork belly burgdoggen. Corned beef tail pork belly short loin chuck drumstick. 40 | 41 | Salami landjaeger pork chop, burgdoggen beef hamburger short loin alcatra filet mignon capicola tail. Swine cow pork ham hock turkey shoulder short loin porchetta tail buffalo meatloaf shank ham frankfurter. Shoulder pork belly tenderloin turkey ball tip drumstick porchetta. Meatball bresaola spare ribs porchetta shoulder andouille pork buffalo picanha swine beef ribs. T-bone meatloaf chicken capicola, strip steak doner turducken pancetta tenderloin short ribs jerky drumstick brisket. 42 | 43 | Shankle cow beef, rump buffalo short loin sirloin t-bone. Bresaola capicola pork pork loin drumstick turkey pig ball tip strip steak sausage landjaeger biltong short loin. Turkey rump shoulder tri-tip landjaeger, corned beef drumstick flank t-bone. Burgdoggen meatloaf pastrami spare ribs pork loin ham hock turkey. 44 | """ 45 | ], 46 | otherStrings: [ 47 | """ 48 | Godfather ipsum dolor sit amet. You can act like a man! I don't trust a doctor who can hardly speak English. What's wrong with being a lawyer? Te salut, Don Corleone. That's my family Kay, that's not me. 49 | """, 50 | """ 51 | Do me this favor. I won't forget it. Ask your friends in the neighborhood about me. They'll tell you I know how to return a favor. Why did you go to the police? Why didn't you come to me first? I'm your older brother, Mike, and I was stepped over! It's not personal. It's business. Just when I thought I was out... they pull me back in. 52 | """, 53 | """ 54 | It's an old habit. I spent my whole life trying not to be careless. Women and children can afford to be careless, but not men. What's the matter with you? Is this what you've become, a Hollywood finocchio who cries like a woman? "Oh, what do I do? What do I do?" What is that nonsense? Ridiculous! You talk about vengeance. Is vengeance going to bring your son back to you? Or my boy to me? I don't like violence, Tom. I'm a businessman; blood is a big expense. 55 | """ 56 | ], 57 | text: Async.uninitialized 58 | 59 | ) 60 | } 61 | 62 | // MARK: Stored Properties 63 | public var hasTextInTextField: Bool 64 | public var expandableDict: [String: Bool] 65 | public var strings: [String] 66 | public var otherStrings: [String] 67 | public var text: Async 68 | 69 | } 70 | 71 | -------------------------------------------------------------------------------- /Sources/Components/Protocols/TextFieldComponentType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 09.04.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | // sourcery: AutoEquatableComponent,AutoHashableComponent 11 | // sourcery: Component = TextFieldComponent,isFrameworkComponent 12 | /// TextFieldComponentType is a protocol for Components that represents a UITextField. 13 | public protocol TextFieldComponentType: StaticHeightComponent { 14 | 15 | // sourcery: defaultValue = """" 16 | /// The String displayed as placeholder text on the UITextField. The default value is an empty string: "". 17 | var placeholder: String { get set } 18 | 19 | // sourcery: defaultValue = """" 20 | // sourcery: skipHashing, skipEquality 21 | /// The String displayed as text on the UITextField. The default value is an empty string: "". 22 | var text: String { get set } 23 | 24 | // sourcery: defaultValue = UIEdgeInsets.zero 25 | // sourcery: skipHashing, skipEquality 26 | /// The insets on the UITextField relative to its root UIView. 27 | /// UIButton. The default value is UIEdgeInsets.zero. 28 | var insets: UIEdgeInsets { get set } 29 | 30 | // sourcery: defaultValue = UIColor.clear 31 | /// The background color of the UITextField. The default value is UIColor.clear. 32 | var backgroundColor: UIColor { get set } 33 | 34 | // sourcery: defaultValue = Alignment.centerLeading 35 | // sourcery: skipHashing, skipEquality 36 | /// The alignment of the underlying SizeLayout. The default value is Alignment.centerLeading. 37 | var alignment: Alignment { get set } 38 | 39 | // sourcery: defaultValue = Flexibility.flexible 40 | // sourcery: skipHashing, skipEquality 41 | /// The flexibility of the underlying SizeLayout. The default value is Flexibility.flexible. 42 | var flexibility: Flexibility { get set } 43 | 44 | // sourcery: defaultValue = "500" 45 | /// The number of milliseconds that must elapse before editingChanged is called. The default value is 500 milliseconds. 46 | var editingChangeDelay: Int { get set } 47 | 48 | // sourcery: defaultValue = "{ _ in }" 49 | // sourcery: skipHashing, skipEquality 50 | /// The configuration applied to the UITextField. The default closure does nothing. 51 | var configuration: (UITextField) -> Void { get set } 52 | 53 | // sourcery: defaultValue = "{ (_: UITextField) -> Void in print("TextField has new text") }" 54 | // sourcery: skipHashing, skipEquality 55 | /// This closure is executed 0.5 seconds after the user has stopped typing on the UITextField. 56 | var editingChanged: (UITextField) -> Void { get set } 57 | 58 | // sourcery: defaultValue = "UITextField.self" 59 | // sourcery: skipHashing, skipEquality 60 | var textFieldType: UITextField.Type { get set } 61 | 62 | // sourcery: defaultValue = "CyanicTextFieldDelegateProxy()" 63 | // sourcery: skipHashing, skipEquality 64 | /// The UITextFieldDelegate for the underlying UITextField. This cannot be set, Cyanic takes care of the 65 | /// implementation. Use the closures to customize functionality. 66 | var delegate: UITextFieldDelegate { get } 67 | 68 | // sourcery: defaultValue = "{ _ in return true }" 69 | // sourcery: skipHashing, skipEquality 70 | /// The closure executed when the textFieldShouldBeginEditing delegate method is called. 71 | var shouldBeginEditing: (UITextField) -> Bool { get set } 72 | 73 | // sourcery: defaultValue = "{ _ in }" 74 | // sourcery: skipHashing, skipEquality 75 | /// The closure executed when the textFieldDidBeginEditing delegate method is called. 76 | var didBeginEditing: (UITextField) -> Void { get set } 77 | 78 | // sourcery: defaultValue = "{ _ in return true }" 79 | // sourcery: skipHashing, skipEquality 80 | /// The closure executed when the textFieldShouldEndEditing delegate method is called. 81 | var shouldEndEditing: (UITextField) -> Bool { get set } 82 | 83 | // sourcery: defaultValue = "{ _ in }" 84 | // sourcery: skipHashing, skipEquality 85 | /// The closure executed when the textFieldDidEndEditing delegate method is called. 86 | var didEndEditing: (UITextField) -> Void { get set } 87 | 88 | // sourcery: defaultValue = "Int.max" 89 | /// The maximum number of characters allowed on the UITextField. 90 | var maximumCharacterCount: Int { get set } 91 | 92 | // sourcery: defaultValue = "{ _ in return true }" 93 | // sourcery: skipHashing, skipEquality 94 | /// The closure executed when the textFieldShouldClear delegate method is called. 95 | var shouldClear: (UITextField) -> Bool { get set } 96 | 97 | // sourcery: defaultValue = "{ _ in return true }" 98 | // sourcery: skipHashing, skipEquality 99 | /// The closure executed when the textFieldShouldReturn delegate method is called. 100 | var shouldReturn: (UITextField) -> Bool { get set } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cyanic 2 | 3 | Cyanic is an iOS framework created at [Feil, Feil, & Feil GmbH](https://ffuf.de/en/) in response to a need for state-driven UI. It borrows heavily 4 | from the concepts of Airbnb's [MvRx](https://github.com/airbnb/MvRx) framework (which our Android developers use) to create a very similar 5 | code base with Android thereby unifying the business logic in both platforms. We use this framework to create complex, performant, and reactive 6 | screens in our projects. 7 | 8 | Cyanic is a Swift only framework. There are no plans to make it compatible with Objective-C. 9 | 10 | ## Installation 11 | ### [CocoaPods](http://cocoapods.org/) 12 | 13 | Requirements: 14 | * Swift 5.0+ 15 | * iOS 10.0+ 16 | 17 | 1. Add the following to your [Podfile](http://guides.cocoapods.org/using/the-podfile.html): 18 | ```rb 19 | pod 'Cyanic' 20 | pod 'LayoutKit', :git => 'https://github.com/hooliooo/LayoutKit.git' // Use this fork until LayoutKit is updated 21 | ``` 22 | 23 | 2. Integrate your dependencies using frameworks: add `use_frameworks!` to your Podfile. 24 | 3. Run `pod install`. 25 | 26 | ## Why we use a forked version of LayoutKit 27 | 28 | LayoutKit is the library that is responsible for most of the UI logic in Cyanic. However, as of April 17, 2019, there are some limitations to the current LayoutKit version in Cocoapods: 29 | 30 | 1. It is not updated to use Swift 5 31 | 2. Cyanic needs access to an internal initializer of the Layouts that allows you to declare the UIView subclass type as an argument. 32 | 33 | Without these changes, Cyanic will continue to use the forked version. 34 | 35 | ## Documentation 36 | 37 | Check out our [wiki](https://github.com/feilfeilundfeil/Cyanic/wiki) for full documentation. 38 | 39 | ## A Simple Example 40 | 41 | A very simple example with expandable functionality: 42 | 43 | ```swift 44 | struct YourState: ExpandableState { 45 | 46 | enum Section: String, CaseIterable { 47 | case first 48 | case second 49 | } 50 | 51 | static var `default`: YourState { 52 | return YourState( 53 | text: "Hello, World!", 54 | expandableDict: YourState.Section.allCases.map { $0.rawValue } 55 | .reduce(into: [String: Bool](), { (current: inout [String: Bool], id: String) -> Void in 56 | current[id] = false 57 | } 58 | ) 59 | } 60 | 61 | var text: String 62 | var expandableDict: [String: Bool] 63 | } 64 | 65 | class YourViewModel: ViewModel { 66 | func showCyanic() { 67 | self.setState { $0.text = "Hello, Cyanic!" } 68 | } 69 | } 70 | 71 | class YourComponentViewController: SingleSectionCollectionComponentViewController { 72 | 73 | private let viewModel: YourViewModel = YourViewModel(initialState: YourState.default) 74 | 75 | override var viewModels: [AnyViewModel] { 76 | return [self.viewModel.asAnyViewModel] 77 | } 78 | 79 | override func buildComponents(_ componentsController: inout ComponentsController) { 80 | withState(self.viewModel) { (state: YourState) -> Void in 81 | componentsController.staticTextComponent { 82 | $0.id = "title" 83 | $0.text = state.text 84 | } 85 | 86 | componentsController.buttonComponent { 87 | $0.id = "button" 88 | $0.onTap = { [weak self] 89 | self?.viewModel.showCyanic() 90 | } 91 | } 92 | 93 | let firstExpandableID: String = YourState.Section.first.rawValue 94 | 95 | let yourExpandable = components.expandableComponent { [weak self] in 96 | guard let s = self else { return } 97 | $0.id = firstExpandableID 98 | $0.contentLayout = LabelContentLayout(text: Text.unattributed("Hello, World!")) 99 | $0.isExpanded = state.expandableDict[firstExpandableID] ?? false 100 | $0.setExpandableState = self.viewModel.setExpandableState 101 | $0.backgroundColor = UIColor.lightGray 102 | $0.height = 55.0 103 | } 104 | 105 | // These ButtonComponents will only show up when yourExpandable is expanded. 106 | if yourExpandable.isExpanded { 107 | for number in 1...5 { 108 | componentsController.buttonComponent { 109 | $0.id = "button\(number)" 110 | $0.title = "\(number)" 111 | $0.onTap = { [weak self] 112 | print("Hello, World from Button \(number)") 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | } 120 | ``` 121 | 122 | ## Contributors 123 | 124 | * Julio Alorro (julio.alorro@ffuf.de) 125 | * Jonas Bark (jonas.bark@ffuf.de) 126 | * Alexander Korus (alexander.korus@ffuf.de) 127 | -------------------------------------------------------------------------------- /Example/ExampleList/CompositeVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompositeVC.swift 3 | // Example 4 | // 5 | // Created by Julio Miguel Alorro on 3/10/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import Cyanic 10 | import Alacrity 11 | import LayoutKit 12 | import RxCocoa 13 | import RxSwift 14 | 15 | class ExampleLoginVC: ComponentViewController { 16 | 17 | init(viewModelOne: ViewModelA, viewModelTwo: ViewModelB) { 18 | self.viewModelOne = viewModelOne 19 | self.viewModelTwo = viewModelTwo 20 | super.init(nibName: nil, bundle: nil) 21 | } 22 | 23 | required init?(coder aDecoder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | deinit { 28 | print("CompositeVC Deallocated") 29 | } 30 | 31 | override func viewDidLoad() { 32 | super.viewDidLoad() 33 | // self.navigationController?.isNavigationBarHidden = true 34 | self.view.backgroundColor = UIColor.white 35 | 36 | self.kio.setUpNavigationItem { 37 | $0.rightBarButtonItems = [ 38 | UIBarButtonItem( 39 | title: "Second", style: UIBarButtonItem.Style.plain, 40 | target: self, 41 | action: #selector(ExampleLoginVC.addButtonTapped) 42 | ), 43 | UIBarButtonItem( 44 | title: "Third", style: UIBarButtonItem.Style.plain, 45 | target: self, 46 | action: #selector(ExampleLoginVC.otherButtonTapped) 47 | ) 48 | ].reversed() 49 | } 50 | } 51 | 52 | let viewModelOne: ViewModelA 53 | let viewModelTwo: ViewModelB 54 | 55 | override var throttleType: ThrottleType { return ThrottleType.debounce(0.1) } 56 | override var width: ComponentViewController.Width { return .exactly(240.0) } 57 | override var viewModels: [AnyViewModel] { 58 | return [AnyViewModel(self.viewModelOne), AnyViewModel(self.viewModelTwo)] 59 | } 60 | 61 | @objc func addButtonTapped() { 62 | self.viewModelOne.addButtonTapped() 63 | self.viewModelTwo.addButtonTapped() 64 | } 65 | 66 | @objc func otherButtonTapped() { 67 | self.viewModelOne.otherButtonTapped() 68 | self.viewModelTwo.otherButtonTapped() 69 | } 70 | 71 | override func buildComponents(_ componentsController: inout ComponentsController) { 72 | let width: CGFloat = componentsController.width 73 | 74 | withState( 75 | viewModel1: self.viewModelOne, 76 | viewModel2: self.viewModelTwo 77 | ) { (state1: StateA, state2: StateB) -> Void in 78 | let isTrue: Bool = state1.isTrue && state2.isTrue 79 | componentsController.staticTextComponent { 80 | $0.id = "First" 81 | $0.text = Text.unattributed(isTrue ? "This should say true when both are true" : "False") 82 | $0.backgroundColor = UIColor.red 83 | $0.style = AlacrityStyle { $0.textColor = UIColor.black } 84 | $0.font = UIFont.systemFont(ofSize: 17.0) 85 | $0.insets = UIEdgeInsets(top: 20.0, left: 20.0, bottom: 20.0, right: 20.0) 86 | $0.width = width 87 | } 88 | 89 | componentsController.add( 90 | StaticTextComponent(id: "Second") 91 | .copy { 92 | $0.text = Text.unattributed(state1.isTrue ? "First state is true" : "False") 93 | $0.backgroundColor = UIColor.gray 94 | $0.style = AlacrityStyle { $0.textColor = UIColor.black } 95 | $0.font = UIFont.systemFont(ofSize: 17.0) 96 | $0.insets = UIEdgeInsets(top: 20.0, left: 20.0, bottom: 20.0, right: 20.0) 97 | $0.width = width 98 | } 99 | ) 100 | 101 | componentsController.add( 102 | StaticTextComponent(id: "Third") 103 | .copy { 104 | $0.text = Text.unattributed(state2.isTrue ? "Second state is true" : "False") 105 | $0.backgroundColor = UIColor.green 106 | $0.style = AlacrityStyle { $0.textColor = UIColor.black } 107 | $0.font = UIFont.systemFont(ofSize: 17.0) 108 | $0.insets = UIEdgeInsets(top: 20.0, left: 20.0, bottom: 20.0, right: 20.0) 109 | $0.width = width 110 | } 111 | ) 112 | 113 | componentsController.staticSpacingComponent { 114 | $0.id = "Blah" 115 | $0.backgroundColor = UIColor.yellow 116 | $0.height = 44.0 117 | } 118 | } 119 | } 120 | 121 | override func invalidate() { 122 | withState(viewModel1: self.viewModelOne, viewModel2: self.viewModelTwo) { (state1: StateA, state2: StateB) -> Void in 123 | switch state1.isTrue { 124 | case true: 125 | self.topAnchorConstraint.constant = -20.0 126 | case false: 127 | self.topAnchorConstraint.constant = 0.0 128 | } 129 | 130 | self.view.layoutIfNeeded() 131 | } 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /Sources/Components/ExpandableComponent/ExpandableComponentLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 15.02.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | /** 11 | The ExpandableComponentLayout is a ComponentLayout that is a subclass of SizeLayout. 12 | Used to create, size, and arrange the subviews associated with ExpandableComponent. 13 | */ 14 | public final class ExpandableComponentLayout: OverlayLayout, ComponentLayout { 15 | 16 | /** 17 | Initializer 18 | - Parameters: 19 | - component: ExpandableComponent instance. Properties from this instance are used to configure the view's 20 | appearance and determine the size of the content. 21 | */ 22 | public init(component: ExpandableComponent) { // swiftlint:disable:this function_body_length 23 | let size: CGSize = component.size 24 | let insets: UIEdgeInsets = component.insets 25 | let contentInsetLayout: InsetLayout = InsetLayout( 26 | insets: UIEdgeInsets(top: insets.top, left: insets.left, bottom: insets.bottom, right: 0.0), 27 | viewReuseId: "\(ExpandableComponentLayout.identifier)RealContentInset", 28 | sublayout: component.contentLayout 29 | ) 30 | 31 | let accessorySizeLayout: SizeLayout = SizeLayout( 32 | minWidth: component.accessoryViewSize.width, 33 | maxWidth: component.accessoryViewSize.width, 34 | minHeight: component.accessoryViewSize.height, 35 | maxHeight: component.accessoryViewSize.height, 36 | alignment: Alignment.center, 37 | flexibility: Flexibility.inflexible, 38 | viewReuseId: "\(ExpandableComponentLayout.identifier)AccessorySize", 39 | viewClass: component.accessoryViewType, 40 | config: component.accessoryViewConfiguration 41 | ) 42 | 43 | let accessoryInsetLayout: InsetLayout = InsetLayout( 44 | insets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: insets.right), 45 | alignment: Alignment.center, 46 | flexibility: accessorySizeLayout.flexibility, 47 | viewReuseId: "\(ExpandableComponentLayout.identifier)AccessoryInset", 48 | sublayout: accessorySizeLayout 49 | ) 50 | 51 | let adjustedSize: CGSize = CGSize( 52 | width: size.width - insets.left - insets.right, 53 | height: component.height - insets.top - insets.bottom 54 | ) 55 | 56 | let contentWidth: CGFloat = contentInsetLayout.measurement(within: adjustedSize).size.width 57 | let accessoryWidth: CGFloat = accessoryInsetLayout.measurement(within: adjustedSize).size.width 58 | 59 | let spacing: CGFloat = size.width - contentWidth - accessoryWidth 60 | 61 | let horizontalStack: StackLayout = StackLayout( 62 | axis: Axis.horizontal, 63 | spacing: spacing, 64 | distribution: StackLayoutDistribution.fillEqualSpacing, 65 | alignment: Alignment.fillLeading, 66 | flexibility: Flexibility.flexible, 67 | viewReuseId: "\(ExpandableComponentLayout.identifier)HorizontalStack", 68 | sublayouts: [contentInsetLayout, accessoryInsetLayout] 69 | ) 70 | 71 | let sizeLayout: SizeLayout = SizeLayout( 72 | minWidth: component.width, 73 | maxWidth: component.width, 74 | minHeight: component.height, 75 | maxHeight: component.height, 76 | flexibility: Flexibility.inflexible, 77 | viewReuseId: "\(ExpandableComponentLayout.identifier)SizeLayout", 78 | sublayout: horizontalStack 79 | ) 80 | 81 | var overlayLayouts: [Layout] = [] 82 | 83 | if let dividerLine = component.dividerLine { 84 | 85 | let sizeLayout: SizeLayout = SizeLayout( 86 | minWidth: component.width - dividerLine.insets.left - dividerLine.insets.right, 87 | maxWidth: component.width, 88 | minHeight: dividerLine.height, 89 | maxHeight: dividerLine.height, 90 | alignment: Alignment.bottomCenter, 91 | flexibility: Flexibility.inflexible, 92 | viewReuseId: "dividerLine", 93 | sublayout: nil, 94 | config: { (view: UIView) -> Void in 95 | view.backgroundColor = dividerLine.backgroundColor 96 | } 97 | ) 98 | 99 | let dividerLineInsetLayout: InsetLayout = InsetLayout( 100 | insets: dividerLine.insets, 101 | sublayout: sizeLayout 102 | ) 103 | 104 | overlayLayouts.append(dividerLineInsetLayout) 105 | } 106 | 107 | super.init( 108 | primaryLayouts: [sizeLayout], 109 | backgroundLayouts: [], 110 | overlayLayouts: overlayLayouts, 111 | alignment: Alignment.centerLeading, 112 | flexibility: Flexibility.flexible, 113 | viewReuseId: ExpandableComponentLayout.identifier, 114 | config: { (view: UIView) -> Void in 115 | component.configuration(view) 116 | view.backgroundColor = component.backgroundColor 117 | } 118 | ) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/Components/Protocols/TextViewComponentType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cyanic 3 | // Created by Julio Miguel Alorro on 16.05.19. 4 | // Licensed under the MIT license. See LICENSE file 5 | // 6 | 7 | import LayoutKit 8 | import UIKit 9 | 10 | // sourcery: AutoEquatableComponent,AutoHashableComponent 11 | // sourcery: Component = TextViewComponent,isFrameworkComponent 12 | /// TextViewComponentType is a protocol for Components that represents a UITextView. 13 | public protocol TextViewComponentType: StaticHeightComponent { 14 | 15 | // sourcery: defaultValue = """" 16 | // sourcery: skipHashing, skipEquality 17 | /// The String displayed as text on the UITextView. The default value is an empty string: "". 18 | var text: String { get set } 19 | 20 | // sourcery: defaultValue = "UIFont.systemFont(ofSize: 13.0)" 21 | // sourcery: skipHashing, skipEquality 22 | /// The font of the Text. The default value is UIFont.systemFont(ofSize: 13.0). 23 | var font: UIFont { get set } 24 | 25 | // sourcery: defaultValue = UIEdgeInsets.zero 26 | // sourcery: skipHashing, skipEquality 27 | /// The insets on the UITextView relative to its root UIView. The default value is UIEdgeInsets.zero. 28 | var insets: UIEdgeInsets { get set } 29 | 30 | // sourcery: defaultValue = UIEdgeInsets.zero 31 | // sourcery: skipHashing, skipEquality 32 | /// The textContainerInset on the UITextView. The default value is UIEdgeInsets.zero. 33 | var textContainerInset: UIEdgeInsets { get set } 34 | 35 | // sourcery: defaultValue = UIColor.clear 36 | /// The background color of the UITextView. The default value is UIColor.clear. 37 | var backgroundColor: UIColor { get set } 38 | 39 | // sourcery: defaultValue = Alignment.fill 40 | // sourcery: skipHashing, skipEquality 41 | /// The alignment of the underlying SizeLayout. The default value is Alignment.fill. 42 | var alignment: Alignment { get set } 43 | 44 | // sourcery: defaultValue = Flexibility.flexible 45 | // sourcery: skipHashing, skipEquality 46 | /// The flexibility of the underlying SizeLayout. The default value is Flexibility.flexible. 47 | var flexibility: Flexibility { get set } 48 | 49 | // sourcery: defaultValue = "{ _ in }" 50 | // sourcery: skipHashing, skipEquality 51 | /// The configuration applied to the UITextField. The default closure does nothing. 52 | var configuration: (UITextView) -> Void { get set } 53 | 54 | // sourcery: defaultValue = "UITextView.self" 55 | // sourcery: skipHashing, skipEquality 56 | var textViewType: UITextView.Type { get set } 57 | 58 | // sourcery: defaultValue = "CyanicTextViewDelegateProxy()" 59 | // sourcery: skipHashing, skipEquality 60 | /// The UITextViewDelegate for the underlying UITextView. This cannot be set, Cyanic takes care of the 61 | /// implementation. Use the closures to customize functionality. 62 | var delegate: UITextViewDelegate { get } 63 | 64 | // sourcery: defaultValue = "{ _ in return true }" 65 | // sourcery: skipHashing, skipEquality 66 | /// The closure executed when the textViewShouldBeginEditing delegate method is called. 67 | var shouldBeginEditing: (UITextView) -> Bool { get set } 68 | 69 | // sourcery: defaultValue = "{ _ in return true }" 70 | // sourcery: skipHashing, skipEquality 71 | /// The closure executed when the textViewShouldEndEditing delegate method is called. 72 | var shouldEndEditing: (UITextView) -> Bool { get set } 73 | 74 | // sourcery: defaultValue = "{ _ in }" 75 | // sourcery: skipHashing, skipEquality 76 | /// The closure executed when the textViewDidBeginEditing delegate method is called. 77 | var didBeginEditing: (UITextView) -> Void { get set } 78 | 79 | // sourcery: defaultValue = "{ _ in }" 80 | // sourcery: skipHashing, skipEquality 81 | /// The closure executed when the textViewDidEndEditing delegate method is called. 82 | var didEndEditing: (UITextView) -> Void { get set } 83 | 84 | // sourcery: defaultValue = "Int.max" 85 | // sourcery: skipHashing, skipEquality 86 | /// The maximum number of characters allowed on the UITextView. 87 | var maximumCharacterCount: Int { get set } 88 | 89 | // sourcery: defaultValue = "{ _ in }" 90 | // sourcery: skipHashing, skipEquality 91 | /// The closure executed when the textViewDidChange delegate method is called. 92 | var didChange: (UITextView) -> Void { get set } 93 | 94 | // sourcery: defaultValue = "{ _ in }" 95 | // sourcery: skipHashing, skipEquality 96 | /// The closure executed when the textViewDidChangeSelection delegate method is called. 97 | var didChangeSelection: (UITextView) -> Void { get set } 98 | 99 | // sourcery: defaultValue = " { _, _, _, _ in return true }" 100 | // sourcery: skipHashing, skipEquality 101 | /// The closure executed when the textView:shouldInteractWithURL:characterRange:interaction delegate method is called. 102 | var shouldInteractWithURLInCharacterRange: (UITextView, URL, NSRange, UITextItemInteraction) -> Bool { get set } // swiftlint:disable:this line_length 103 | 104 | // sourcery: defaultValue = " { _, _, _, _ in return true }" 105 | // sourcery: skipHashing, skipEquality 106 | /// The closure executed when the textView:shouldInteractWithTextAttachement:characterRange:interaction delegate 107 | /// method is called. 108 | var shouldInteractWithTextAttachmentInCharacterRange: (UITextView, NSTextAttachment, NSRange, UITextItemInteraction) -> Bool { get set } 109 | // swiftlint:disable:previous line_length 110 | 111 | } 112 | -------------------------------------------------------------------------------- /Tests/StateStoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateStoreTests.swift 3 | // Tests 4 | // 5 | // Created by Julio Miguel Alorro on 3/21/19. 6 | // Copyright © 2019 Feil, Feil, & Feil GmbH. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | @testable import Cyanic 12 | 13 | class StateStoreTests: QuickSpec { 14 | 15 | struct TestState: State { 16 | 17 | static var `default`: StateStoreTests.TestState { 18 | return TestState(count: 0, string: "Hello, World") 19 | } 20 | 21 | var count: Int 22 | var string: String 23 | } 24 | 25 | func createStore() -> StateStore { 26 | return StateStore(initialState: TestState.default) 27 | } 28 | 29 | override func spec() { 30 | describe("getState method") { 31 | let store: StateStore = self.createStore() 32 | it("should be asynchronous") { 33 | var count: Int = 0 34 | store.getState(with: { _ in count += 1}) 35 | expect(count).to(equal(0)) 36 | expect(count).toEventually(equal(1)) 37 | } 38 | } 39 | 40 | describe("setState method") { 41 | let store: StateStore = self.createStore() 42 | it("should be asynchronous and setState should be resolved before getState") { 43 | var count: Int = 0 44 | store.getState(with: { count = $0.count }) 45 | store.setState(with: { $0.count = 2}) 46 | expect(count).to(equal(0)) 47 | expect(count).toEventually(equal(2)) 48 | } 49 | } 50 | 51 | describe("state observable") { 52 | let store: StateStore = self.createStore() 53 | it("should only change if the new State is different from the old State") { 54 | var count: Int = 0 55 | _ = store.state 56 | .bind(onNext: { _ in count += 1}) 57 | 58 | expect(count).to(equal(1)) // Incremented due to the fact that BehaviorRelay 59 | // replays it's current value on subscribe 60 | store.setState(with: { $0 = $0}) 61 | expect(count).to(equal(1)) 62 | store.setState(with: { $0.count = 1 }) 63 | expect(count).toEventually(equal(2)) 64 | } 65 | } 66 | 67 | describe("stress test") { 68 | let store: StateStore = self.createStore() 69 | it("should be able to handle concurrency") { 70 | var concurrentQueue1Count: [Int] = [] 71 | var concurrentQueue2Count: [Int] = [] 72 | 73 | let concurrentQueue: DispatchQueue = DispatchQueue( 74 | label: "Concurrent1", 75 | qos: DispatchQoS.default, 76 | attributes: .concurrent 77 | ) 78 | 79 | let firstLoopStart: Int = 0 80 | let firstLoopEnd: Int = 1_000 81 | 82 | let secondLoopStart: Int = firstLoopEnd + 1 83 | let secondLoopEnd: Int = firstLoopEnd * 2 84 | 85 | var finalCount: Int = 0 86 | 87 | concurrentQueue.async { 88 | for num in secondLoopStart...secondLoopEnd { 89 | store.setState(with: { 90 | $0.count = num 91 | print("Async Num from Second Loop: \(num)") 92 | concurrentQueue2Count.append(num) 93 | 94 | store.setState(with: { 95 | $0.string = "\(Int.random(in: Int.min...Int.max))" 96 | store.setState(with: { 97 | $0.count = num 98 | print("Async Num from Second Loop: \(num)") 99 | concurrentQueue2Count.append(num) 100 | 101 | store.setState(with: { 102 | $0.string = "\(Int.random(in: Int.min...Int.max))" 103 | 104 | }) 105 | }) 106 | }) 107 | }) 108 | } 109 | } 110 | 111 | concurrentQueue.async { 112 | for _ in firstLoopStart...firstLoopEnd { 113 | store.getState(with: { 114 | finalCount = $0.count 115 | }) 116 | } 117 | } 118 | 119 | concurrentQueue.async { 120 | for num in firstLoopStart...firstLoopEnd { 121 | store.setState(with: { 122 | $0.count = num 123 | print("Async Num from First Loop: \(num)") 124 | concurrentQueue1Count.append(num) 125 | }) 126 | } 127 | } 128 | 129 | expect(concurrentQueue1Count).toEventually(contain(firstLoopEnd), description: "Stress count for async loop") 130 | expect(concurrentQueue2Count).toEventually(contain(secondLoopEnd), description: "Stress count for async loop") 131 | expect(finalCount).toEventually(satisfyAnyOf(equal(firstLoopEnd), equal(secondLoopEnd)), description: "\(finalCount) should equal \(firstLoopEnd) or \(secondLoopEnd)") 132 | 133 | } 134 | } 135 | } 136 | } 137 | --------------------------------------------------------------------------------