├── .gitignore ├── Cartfile ├── Cartfile.resolved ├── Documentation └── Overview.md ├── Examples.xcodeproj └── project.pbxproj ├── Examples ├── AppDelegate.swift ├── Assets.xcassets │ └── AppIcon.appiconset │ │ └── Contents.json ├── Base.lproj │ └── LaunchScreen.storyboard ├── DynamicHeightTableCells.swift └── Info.plist ├── LICENSE ├── Package.swift ├── PortalView.playground ├── Contents.swift ├── contents.xcplayground └── timeline.xctimeline ├── PortalView.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── xcshareddata │ └── xcschemes │ └── PortalView.xcscheme ├── PortalView.xcworkspace └── contents.xcworkspacedata ├── README.md ├── Sources ├── Component.swift ├── Components │ ├── Button.swift │ ├── Carousel.swift │ ├── Collection.swift │ ├── Image.swift │ ├── Label.swift │ ├── MapView.swift │ ├── NavigationBar.swift │ ├── Progress.swift │ ├── Segmented.swift │ ├── Spinner.swift │ ├── Table.swift │ └── TextField.swift ├── Extensions.swift ├── Info.plist ├── Layout.swift ├── LayoutEngine.swift ├── Mailbox.swift ├── Portal.swift ├── ProgressCounter.swift ├── StyleSheet.swift ├── UIKit │ ├── MessageDispatcher.swift │ ├── PortalCarouselView.swift │ ├── PortalCollectionView.swift │ ├── PortalCollectionViewCell.swift │ ├── PortalMapView.swift │ ├── PortalNavigationController.swift │ ├── PortalTableView.swift │ ├── PortalTableViewCell.swift │ ├── PortalViewController.swift │ ├── Renderers │ │ ├── ButtonRenderer.swift │ │ ├── CarouselRenderer.swift │ │ ├── CollectionRenderer.swift │ │ ├── ComponentRenderer.swift │ │ ├── ContainerRenderer.swift │ │ ├── FontRenderer.swift │ │ ├── ImageViewRenderer.swift │ │ ├── LabelRenderer.swift │ │ ├── MapViewRenderer.swift │ │ ├── NavigationBarTitleRenderer.swift │ │ ├── ProgressRenderer.swift │ │ ├── SegmentedRenderer.swift │ │ ├── SpinnerRenderer.swift │ │ ├── TableRenderer.swift │ │ ├── TextFieldRenderer.swift │ │ └── TouchableRenderer.swift │ ├── UIKitComponentManager.swift │ ├── UIKitRenderable.swift │ ├── UIKitRenderer.swift │ └── UIViewExtensions.swift └── ZipList.swift └── Tests ├── Info.plist ├── LinuxMain.swift └── PortalViewTests └── PortalViewTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store 3 | 4 | # Xcode 5 | build/ 6 | *.pbxuser 7 | !default.pbxuser 8 | *.mode1v3 9 | !default.mode1v3 10 | *.mode2v3 11 | !default.mode2v3 12 | *.perspectivev3 13 | !default.perspectivev3 14 | xcuserdata 15 | *.xccheckout 16 | profile 17 | *.moved-aside 18 | DerivedData 19 | *.hmap 20 | *.ipa 21 | 22 | # Bundler 23 | .bundle 24 | 25 | # Dependency managers 26 | Carthage/ 27 | .build/ 28 | -------------------------------------------------------------------------------- /Cartfile: -------------------------------------------------------------------------------- 1 | github "guidomb/yoga" "carthage-support" 2 | -------------------------------------------------------------------------------- /Cartfile.resolved: -------------------------------------------------------------------------------- 1 | github "guidomb/yoga" "8d2c33a8e951510486353bbaedbbf389345bd1d1" 2 | -------------------------------------------------------------------------------- /Documentation/Overview.md: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | ## About 5 | 6 | TODO Talk a little bit about the motivations and pros 7 | 8 | ### Components 9 | 10 | Components describe the UI widgets that will be rendered on screen. Portal provide most of the basic components required to develop most applications. Also components are very composable providing the flexibility and basic structure to create more complex widgets. 11 | 12 | One of Portal's core principles is type safety. That is why all components are generic over the type of messages they can emit. For example, lets say that we are working on a social network application where users can post messages and other users can like them. The action of liking a post could be model with the following message type. 13 | 14 | ```swift 15 | enum Message { 16 | 17 | case like(postId: String) 18 | // Other messages 19 | 20 | } 21 | ``` 22 | 23 | > We are using an `enum` because in a real world application you would have more than one action 24 | 25 | and then we could define a like button as follows 26 | 27 | ```swift 28 | func likeButton(postId: String) -> Component { 29 | return button( 30 | properties: properties() { 31 | $0.text = "Like!" 32 | $0.onTap = .like(postId: postId) 33 | } 34 | style: buttonStyleSheet() { base, button in 35 | base.backgroundColor = .black 36 | button.textColor = .white 37 | button.textSize = 17 38 | }, 39 | layout: layout() { 40 | $0.flex = flex() { 41 | $0.grow = .one 42 | } 43 | $0.margin = .by(edge: edge() { 44 | $0.left = 5 45 | $0.right = 5 46 | }) 47 | $0.height = Dimension(value: 50) 48 | } 49 | ) 50 | } 51 | ``` 52 | 53 | There quite a few things to notice about the previous code snippet: 54 | 55 | * The UI is 100% defined in plain Swift code. You can reuse components just by extracting them into regular functions. Having your UI elements defined in code makes it easier for debugging, reusability and performing code diffs when reviewing patches. 56 | * Concerns are strictly separated. All components have a set of properties that defined their behavior. A stylesheet that defines the component's look and feel and a layout that defines the component's position and size. 57 | * (A subset of) [Flexbox](https://www.w3schools.com/CSS/css3_flexbox.asp) is used for layout. Implemented using facebook's [Yoga](https://github.com/facebook/yoga) library. 58 | * There are no delegates, selectors or callbacks that are needed to handle user interactions. All you need to do is specify the message that will be sent when the user taps the button. In this case `.like(postId: postId)`. 59 | * Properties, stylesheet and layout are configured using a DSL-like syntax. 60 | 61 | [`Component`](https://github.com/guidomb/PortalView/blob/master/Sources/Component.swift) is an `enum` (or sum type) where each of its possible values correspond to a core UI widget that can be found in any modern mobile UI library, like UIKit. 62 | 63 | Because Portal was conceived with the idea of making iOS applications there is almost a one to one relation between Portal's components and UIKit components. But this does not mean that there cannot or won't be differences. Portal's spirit is to make common tasks easier, that is why you'll notice that some things that required several lines of code tweaking a UIKit component can be achieved with one or two lines in Portal. 64 | 65 | #### Organizing components 66 | 67 | As it can be seen, defining a component programmatically can take quite a few lines depending on the level of customization. That is way it is recommended to extract components into functions with a clear name. Like we did in the previous example. `likeButton(postId:)` clearly communicates that we are creating a button that when tapping it will send a message telling that the user wants to like the post with the given id. 68 | 69 | At the end of the day it is just a regular Swift function. This also helps a lot with code reusability. Every time you want to show a like button, all you need to do is call the `likeButton(postId:)` function with the appropriate post id. In case you need to change about the like button, there is only one place that you'll have to look into. 70 | 71 | The basic idea is to extract components into their own functions and create more complex components by composing other simpler components. For example, lets say that we now add a comment feature. First thing we need to do is add a new message. 72 | 73 | ```swift 74 | enum Message { 75 | 76 | case like(postId: String) 77 | case showComments(postId: String) 78 | case saveComment(postId: String, comment: String) 79 | 80 | } 81 | ``` 82 | 83 | then we add a comments button that will send a message to display the list of comments for a given post. 84 | 85 | ```swift 86 | func commentsButton(postId: String, commentsCount: UInt) -> Component { 87 | return button( 88 | properties: properties() { 89 | $0.text = "Comments (\(commentsCount))" 90 | $0.onTap = . showComments(postId: postId) 91 | } 92 | style: buttonStyleSheet() { base, button in 93 | base.backgroundColor = .black 94 | button.textColor = .white 95 | button.textSize = 17 96 | }, 97 | layout: layout() { 98 | $0.flex = flex() { 99 | $0.grow = .one 100 | } 101 | $0.margin = .by(edge: edge() { 102 | $0.left = 5 103 | $0.right = 5 104 | }) 105 | $0.height = Dimension(value: 50) 106 | } 107 | ) 108 | } 109 | ``` 110 | 111 | > If you want to share styles between components, lets say you want all buttons in the application to look the same, then the best way to do that is by sharing a common stylesheet between all components. 112 | 113 | Now we can create a new component that will get rendered every time a post is render. This component will hold both the like and comments button. 114 | 115 | ```swift 116 | func postActionBar(for post: Post) -> Component { 117 | return container( 118 | children:[ 119 | likeButton(postId: post.id), 120 | commentsButton( 121 | postId: post.id, 122 | commentsCount: post.commentsCount 123 | ) 124 | ] 125 | ) 126 | } 127 | ``` 128 | 129 | where `Post` is a model object with the following properties 130 | 131 | ```swift 132 | struct Post { 133 | 134 | let id: String 135 | let text: String 136 | let commentsCount: UInt 137 | 138 | } 139 | ``` 140 | 141 | #### Sharing components between different modules 142 | 143 | It is a good practice to extract code in order to reuse it between different project. Developers create libraries or frameworks, even for internal projects. For example one could create a shared library that contains all common UI components and service logic shared by all applications in a given organization. 144 | 145 | Again, based on the previous example, we could have a library that will be shared between several applications that want to display, like and comment posts. Such library should define an export its own components which should define the messages supported by them. Lets assume that such library is called `PostsUI`. 146 | 147 | The problems come when you want to compose components that are defined in your application with components from the shared library. Types won't match. Components from the `PostsUI` library will have type `Component` while components defined in your applications will have type `Component`. 148 | 149 | To solve this problem you can *"map"* components from the `PostsUI` library over your application's components. All you need to do is call the component's `map` function a provide function of type `(PostsUI.Message) => Message`. For example lets say that your application defines the following message type. 150 | 151 | ```swift 152 | enum Message { 153 | 154 | case logIn(username: String, password: String) 155 | case logOut 156 | case post(message: PostsUI.Message) 157 | 158 | } 159 | ``` 160 | 161 | and you want to compose different components in a container view like 162 | 163 | ```swift 164 | let child1: Component 165 | let child2: Component 166 | let component: Component = container( 167 | children: [ 168 | child1, 169 | child2, 170 | PostsUI.likeButton(postId: "1234") // This line won't compile 171 | ] 172 | ) 173 | ``` 174 | 175 | The previous code snippet won't compile because the message types don't match. `likeButton` returns `Component` and the container expects all children to be of type `Component`. Fixing this is quite easy 176 | 177 | ```swift 178 | let child1: Component 179 | let child2: Component 180 | let child3: Component = PostsUI.likeButton(postId: "1234").map { 181 | .posts(message: $0) 182 | } 183 | let component: Component = container( 184 | children: [ 185 | child1, 186 | child2, 187 | child3 188 | ] 189 | ) 190 | ``` 191 | 192 | All we ended up doing was wrapping the message sent by the like button in a `Message.posts` message. 193 | 194 | #### Properties 195 | 196 | TODO 197 | 198 | #### Stylesheet 199 | 200 | TODO 201 | 202 | #### Layout 203 | 204 | TODO 205 | 206 | ### Root components 207 | 208 | TODO 209 | 210 | ### Renderer & presenter 211 | 212 | TODO Talk about UIKitComponentManager 213 | 214 | ### Handling component messages 215 | 216 | TODO Talk about mailboxes 217 | 218 | ### Architecture 219 | 220 | TODO Talk about the architecture and things like renderers the presenter 221 | how to customize or interact with legacy code 222 | 223 | #### State management 224 | 225 | TODO talk about a the way Portal recommends handling state. Talk about Router and navigation and why I considered it to also be state management. 226 | 227 | ### Cross platform 228 | 229 | PortalView is 100% written in Swift and it is (potentially) cross-platform because it does not depend on UIKit at all. 230 | 231 | TODO explain better add graphs -------------------------------------------------------------------------------- /Examples.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 9D3C0A941E59807D008F63A4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D3C0A931E59807D008F63A4 /* AppDelegate.swift */; }; 11 | 9D3C0A9B1E59807D008F63A4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9D3C0A9A1E59807D008F63A4 /* Assets.xcassets */; }; 12 | 9D3C0A9E1E59807D008F63A4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9D3C0A9C1E59807D008F63A4 /* LaunchScreen.storyboard */; }; 13 | 9D3C0AA91E5980F5008F63A4 /* YogaKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3C0AA81E5980F5008F63A4 /* YogaKit.framework */; }; 14 | 9D3C0AAB1E59811A008F63A4 /* DynamicHeightTableCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D3C0AAA1E59811A008F63A4 /* DynamicHeightTableCells.swift */; }; 15 | 9D3C0ABA1E5A87D9008F63A4 /* PortalView.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3C0AB91E5A87D9008F63A4 /* PortalView.framework */; }; 16 | 9D3C0ABB1E5A87D9008F63A4 /* PortalView.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3C0AB91E5A87D9008F63A4 /* PortalView.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 17 | /* End PBXBuildFile section */ 18 | 19 | /* Begin PBXCopyFilesBuildPhase section */ 20 | 9D3C0ABC1E5A87D9008F63A4 /* Embed Frameworks */ = { 21 | isa = PBXCopyFilesBuildPhase; 22 | buildActionMask = 2147483647; 23 | dstPath = ""; 24 | dstSubfolderSpec = 10; 25 | files = ( 26 | 9D3C0ABB1E5A87D9008F63A4 /* PortalView.framework in Embed Frameworks */, 27 | ); 28 | name = "Embed Frameworks"; 29 | runOnlyForDeploymentPostprocessing = 0; 30 | }; 31 | /* End PBXCopyFilesBuildPhase section */ 32 | 33 | /* Begin PBXFileReference section */ 34 | 9D3C0A901E59807D008F63A4 /* Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Examples.app; sourceTree = BUILT_PRODUCTS_DIR; }; 35 | 9D3C0A931E59807D008F63A4 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 36 | 9D3C0A9A1E59807D008F63A4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 37 | 9D3C0A9D1E59807D008F63A4 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 38 | 9D3C0A9F1E59807D008F63A4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 39 | 9D3C0AA81E5980F5008F63A4 /* YogaKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = YogaKit.framework; path = Carthage/Build/iOS/YogaKit.framework; sourceTree = ""; }; 40 | 9D3C0AAA1E59811A008F63A4 /* DynamicHeightTableCells.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DynamicHeightTableCells.swift; sourceTree = ""; }; 41 | 9D3C0AB91E5A87D9008F63A4 /* PortalView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = PortalView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 42 | /* End PBXFileReference section */ 43 | 44 | /* Begin PBXFrameworksBuildPhase section */ 45 | 9D3C0A8D1E59807D008F63A4 /* Frameworks */ = { 46 | isa = PBXFrameworksBuildPhase; 47 | buildActionMask = 2147483647; 48 | files = ( 49 | 9D3C0AA91E5980F5008F63A4 /* YogaKit.framework in Frameworks */, 50 | 9D3C0ABA1E5A87D9008F63A4 /* PortalView.framework in Frameworks */, 51 | ); 52 | runOnlyForDeploymentPostprocessing = 0; 53 | }; 54 | /* End PBXFrameworksBuildPhase section */ 55 | 56 | /* Begin PBXGroup section */ 57 | 9D3C0A871E59807D008F63A4 = { 58 | isa = PBXGroup; 59 | children = ( 60 | 9D3C0A921E59807D008F63A4 /* Sources */, 61 | 9D3C0A911E59807D008F63A4 /* Products */, 62 | 9D3C0AA51E5980EA008F63A4 /* Frameworks */, 63 | ); 64 | sourceTree = ""; 65 | }; 66 | 9D3C0A911E59807D008F63A4 /* Products */ = { 67 | isa = PBXGroup; 68 | children = ( 69 | 9D3C0A901E59807D008F63A4 /* Examples.app */, 70 | ); 71 | name = Products; 72 | sourceTree = ""; 73 | }; 74 | 9D3C0A921E59807D008F63A4 /* Sources */ = { 75 | isa = PBXGroup; 76 | children = ( 77 | 9D3C0AAA1E59811A008F63A4 /* DynamicHeightTableCells.swift */, 78 | 9D3C0A931E59807D008F63A4 /* AppDelegate.swift */, 79 | 9D3C0A9A1E59807D008F63A4 /* Assets.xcassets */, 80 | 9D3C0A9C1E59807D008F63A4 /* LaunchScreen.storyboard */, 81 | 9D3C0A9F1E59807D008F63A4 /* Info.plist */, 82 | ); 83 | name = Sources; 84 | path = Examples; 85 | sourceTree = ""; 86 | }; 87 | 9D3C0AA51E5980EA008F63A4 /* Frameworks */ = { 88 | isa = PBXGroup; 89 | children = ( 90 | 9D3C0AB91E5A87D9008F63A4 /* PortalView.framework */, 91 | 9D3C0AA81E5980F5008F63A4 /* YogaKit.framework */, 92 | ); 93 | name = Frameworks; 94 | sourceTree = ""; 95 | }; 96 | /* End PBXGroup section */ 97 | 98 | /* Begin PBXNativeTarget section */ 99 | 9D3C0A8F1E59807D008F63A4 /* Examples */ = { 100 | isa = PBXNativeTarget; 101 | buildConfigurationList = 9D3C0AA21E59807D008F63A4 /* Build configuration list for PBXNativeTarget "Examples" */; 102 | buildPhases = ( 103 | 9D3C0A8C1E59807D008F63A4 /* Sources */, 104 | 9D3C0A8D1E59807D008F63A4 /* Frameworks */, 105 | 9D3C0A8E1E59807D008F63A4 /* Resources */, 106 | 9D3C0AAC1E598132008F63A4 /* Carthage */, 107 | 9D3C0ABC1E5A87D9008F63A4 /* Embed Frameworks */, 108 | ); 109 | buildRules = ( 110 | ); 111 | dependencies = ( 112 | ); 113 | name = Examples; 114 | productName = Examples; 115 | productReference = 9D3C0A901E59807D008F63A4 /* Examples.app */; 116 | productType = "com.apple.product-type.application"; 117 | }; 118 | /* End PBXNativeTarget section */ 119 | 120 | /* Begin PBXProject section */ 121 | 9D3C0A881E59807D008F63A4 /* Project object */ = { 122 | isa = PBXProject; 123 | attributes = { 124 | LastSwiftUpdateCheck = 0820; 125 | LastUpgradeCheck = 0820; 126 | ORGANIZATIONNAME = "Guido Marucci Blas"; 127 | TargetAttributes = { 128 | 9D3C0A8F1E59807D008F63A4 = { 129 | CreatedOnToolsVersion = 8.2.1; 130 | DevelopmentTeam = 36UGBW7HEL; 131 | ProvisioningStyle = Automatic; 132 | }; 133 | }; 134 | }; 135 | buildConfigurationList = 9D3C0A8B1E59807D008F63A4 /* Build configuration list for PBXProject "Examples" */; 136 | compatibilityVersion = "Xcode 3.2"; 137 | developmentRegion = English; 138 | hasScannedForEncodings = 0; 139 | knownRegions = ( 140 | en, 141 | Base, 142 | ); 143 | mainGroup = 9D3C0A871E59807D008F63A4; 144 | productRefGroup = 9D3C0A911E59807D008F63A4 /* Products */; 145 | projectDirPath = ""; 146 | projectRoot = ""; 147 | targets = ( 148 | 9D3C0A8F1E59807D008F63A4 /* Examples */, 149 | ); 150 | }; 151 | /* End PBXProject section */ 152 | 153 | /* Begin PBXResourcesBuildPhase section */ 154 | 9D3C0A8E1E59807D008F63A4 /* Resources */ = { 155 | isa = PBXResourcesBuildPhase; 156 | buildActionMask = 2147483647; 157 | files = ( 158 | 9D3C0A9E1E59807D008F63A4 /* LaunchScreen.storyboard in Resources */, 159 | 9D3C0A9B1E59807D008F63A4 /* Assets.xcassets in Resources */, 160 | ); 161 | runOnlyForDeploymentPostprocessing = 0; 162 | }; 163 | /* End PBXResourcesBuildPhase section */ 164 | 165 | /* Begin PBXShellScriptBuildPhase section */ 166 | 9D3C0AAC1E598132008F63A4 /* Carthage */ = { 167 | isa = PBXShellScriptBuildPhase; 168 | buildActionMask = 2147483647; 169 | files = ( 170 | ); 171 | inputPaths = ( 172 | "$(SRCROOT)/Carthage/Build/iOS/YogaKit.framework", 173 | ); 174 | name = Carthage; 175 | outputPaths = ( 176 | ); 177 | runOnlyForDeploymentPostprocessing = 0; 178 | shellPath = /bin/sh; 179 | shellScript = "/usr/local/bin/carthage copy-frameworks"; 180 | }; 181 | /* End PBXShellScriptBuildPhase section */ 182 | 183 | /* Begin PBXSourcesBuildPhase section */ 184 | 9D3C0A8C1E59807D008F63A4 /* Sources */ = { 185 | isa = PBXSourcesBuildPhase; 186 | buildActionMask = 2147483647; 187 | files = ( 188 | 9D3C0A941E59807D008F63A4 /* AppDelegate.swift in Sources */, 189 | 9D3C0AAB1E59811A008F63A4 /* DynamicHeightTableCells.swift in Sources */, 190 | ); 191 | runOnlyForDeploymentPostprocessing = 0; 192 | }; 193 | /* End PBXSourcesBuildPhase section */ 194 | 195 | /* Begin PBXVariantGroup section */ 196 | 9D3C0A9C1E59807D008F63A4 /* LaunchScreen.storyboard */ = { 197 | isa = PBXVariantGroup; 198 | children = ( 199 | 9D3C0A9D1E59807D008F63A4 /* Base */, 200 | ); 201 | name = LaunchScreen.storyboard; 202 | sourceTree = ""; 203 | }; 204 | /* End PBXVariantGroup section */ 205 | 206 | /* Begin XCBuildConfiguration section */ 207 | 9D3C0AA01E59807D008F63A4 /* Debug */ = { 208 | isa = XCBuildConfiguration; 209 | buildSettings = { 210 | ALWAYS_SEARCH_USER_PATHS = NO; 211 | CLANG_ANALYZER_NONNULL = YES; 212 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 213 | CLANG_CXX_LIBRARY = "libc++"; 214 | CLANG_ENABLE_MODULES = YES; 215 | CLANG_ENABLE_OBJC_ARC = YES; 216 | CLANG_WARN_BOOL_CONVERSION = YES; 217 | CLANG_WARN_CONSTANT_CONVERSION = YES; 218 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 219 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 220 | CLANG_WARN_EMPTY_BODY = YES; 221 | CLANG_WARN_ENUM_CONVERSION = YES; 222 | CLANG_WARN_INFINITE_RECURSION = YES; 223 | CLANG_WARN_INT_CONVERSION = YES; 224 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 225 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 226 | CLANG_WARN_UNREACHABLE_CODE = YES; 227 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 228 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 229 | COPY_PHASE_STRIP = NO; 230 | DEBUG_INFORMATION_FORMAT = dwarf; 231 | ENABLE_STRICT_OBJC_MSGSEND = YES; 232 | ENABLE_TESTABILITY = YES; 233 | GCC_C_LANGUAGE_STANDARD = gnu99; 234 | GCC_DYNAMIC_NO_PIC = NO; 235 | GCC_NO_COMMON_BLOCKS = YES; 236 | GCC_OPTIMIZATION_LEVEL = 0; 237 | GCC_PREPROCESSOR_DEFINITIONS = ( 238 | "DEBUG=1", 239 | "$(inherited)", 240 | ); 241 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 242 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 243 | GCC_WARN_UNDECLARED_SELECTOR = YES; 244 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 245 | GCC_WARN_UNUSED_FUNCTION = YES; 246 | GCC_WARN_UNUSED_VARIABLE = YES; 247 | IPHONEOS_DEPLOYMENT_TARGET = 10.2; 248 | MTL_ENABLE_DEBUG_INFO = YES; 249 | ONLY_ACTIVE_ARCH = YES; 250 | SDKROOT = iphoneos; 251 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 252 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 253 | TARGETED_DEVICE_FAMILY = "1,2"; 254 | }; 255 | name = Debug; 256 | }; 257 | 9D3C0AA11E59807D008F63A4 /* Release */ = { 258 | isa = XCBuildConfiguration; 259 | buildSettings = { 260 | ALWAYS_SEARCH_USER_PATHS = NO; 261 | CLANG_ANALYZER_NONNULL = YES; 262 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 263 | CLANG_CXX_LIBRARY = "libc++"; 264 | CLANG_ENABLE_MODULES = YES; 265 | CLANG_ENABLE_OBJC_ARC = YES; 266 | CLANG_WARN_BOOL_CONVERSION = YES; 267 | CLANG_WARN_CONSTANT_CONVERSION = YES; 268 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 269 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 270 | CLANG_WARN_EMPTY_BODY = YES; 271 | CLANG_WARN_ENUM_CONVERSION = YES; 272 | CLANG_WARN_INFINITE_RECURSION = YES; 273 | CLANG_WARN_INT_CONVERSION = YES; 274 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 275 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 276 | CLANG_WARN_UNREACHABLE_CODE = YES; 277 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 278 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 279 | COPY_PHASE_STRIP = NO; 280 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 281 | ENABLE_NS_ASSERTIONS = NO; 282 | ENABLE_STRICT_OBJC_MSGSEND = YES; 283 | GCC_C_LANGUAGE_STANDARD = gnu99; 284 | GCC_NO_COMMON_BLOCKS = YES; 285 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 286 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 287 | GCC_WARN_UNDECLARED_SELECTOR = YES; 288 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 289 | GCC_WARN_UNUSED_FUNCTION = YES; 290 | GCC_WARN_UNUSED_VARIABLE = YES; 291 | IPHONEOS_DEPLOYMENT_TARGET = 10.2; 292 | MTL_ENABLE_DEBUG_INFO = NO; 293 | SDKROOT = iphoneos; 294 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 295 | TARGETED_DEVICE_FAMILY = "1,2"; 296 | VALIDATE_PRODUCT = YES; 297 | }; 298 | name = Release; 299 | }; 300 | 9D3C0AA31E59807D008F63A4 /* Debug */ = { 301 | isa = XCBuildConfiguration; 302 | buildSettings = { 303 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 304 | DEVELOPMENT_TEAM = 36UGBW7HEL; 305 | FRAMEWORK_SEARCH_PATHS = ( 306 | "$(inherited)", 307 | "$(PROJECT_DIR)/build/Debug-iphoneos", 308 | "$(PROJECT_DIR)/Carthage/Build/iOS", 309 | ); 310 | INFOPLIST_FILE = Examples/Info.plist; 311 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 312 | PRODUCT_BUNDLE_IDENTIFIER = me.guidomb.PortalView.Examples; 313 | PRODUCT_NAME = "$(TARGET_NAME)"; 314 | SWIFT_VERSION = 3.0; 315 | }; 316 | name = Debug; 317 | }; 318 | 9D3C0AA41E59807D008F63A4 /* Release */ = { 319 | isa = XCBuildConfiguration; 320 | buildSettings = { 321 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 322 | DEVELOPMENT_TEAM = 36UGBW7HEL; 323 | FRAMEWORK_SEARCH_PATHS = ( 324 | "$(inherited)", 325 | "$(PROJECT_DIR)/build/Debug-iphoneos", 326 | "$(PROJECT_DIR)/Carthage/Build/iOS", 327 | ); 328 | INFOPLIST_FILE = Examples/Info.plist; 329 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 330 | PRODUCT_BUNDLE_IDENTIFIER = me.guidomb.PortalView.Examples; 331 | PRODUCT_NAME = "$(TARGET_NAME)"; 332 | SWIFT_VERSION = 3.0; 333 | }; 334 | name = Release; 335 | }; 336 | /* End XCBuildConfiguration section */ 337 | 338 | /* Begin XCConfigurationList section */ 339 | 9D3C0A8B1E59807D008F63A4 /* Build configuration list for PBXProject "Examples" */ = { 340 | isa = XCConfigurationList; 341 | buildConfigurations = ( 342 | 9D3C0AA01E59807D008F63A4 /* Debug */, 343 | 9D3C0AA11E59807D008F63A4 /* Release */, 344 | ); 345 | defaultConfigurationIsVisible = 0; 346 | defaultConfigurationName = Release; 347 | }; 348 | 9D3C0AA21E59807D008F63A4 /* Build configuration list for PBXNativeTarget "Examples" */ = { 349 | isa = XCConfigurationList; 350 | buildConfigurations = ( 351 | 9D3C0AA31E59807D008F63A4 /* Debug */, 352 | 9D3C0AA41E59807D008F63A4 /* Release */, 353 | ); 354 | defaultConfigurationIsVisible = 0; 355 | defaultConfigurationName = Release; 356 | }; 357 | /* End XCConfigurationList section */ 358 | }; 359 | rootObject = 9D3C0A881E59807D008F63A4 /* Project object */; 360 | } 361 | -------------------------------------------------------------------------------- /Examples/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Examples 4 | // 5 | // Created by Guido Marucci Blas on 2/18/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import PortalView 11 | 12 | @UIApplicationMain 13 | class AppDelegate: UIResponder, UIApplicationDelegate { 14 | 15 | var window: UIWindow? 16 | 17 | 18 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 19 | window = UIWindow(frame: UIScreen.main.bounds) 20 | 21 | let presenter = UIKitComponentManager>(window: window!, customComponentRenderer: VoidCustomComponentRenderer()) 22 | presenter.isDebugModeEnabled = false 23 | _ = presenter.present(component: dynamicHeightTable(), with: .simple, modally: false) 24 | 25 | window?.makeKeyAndVisible() 26 | return true 27 | } 28 | 29 | } 30 | 31 | -------------------------------------------------------------------------------- /Examples/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 | "info" : { 90 | "version" : 1, 91 | "author" : "xcode" 92 | } 93 | } -------------------------------------------------------------------------------- /Examples/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 | 27 | 28 | -------------------------------------------------------------------------------- /Examples/DynamicHeightTableCells.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DynamicHeightTableCells.swift 3 | // PortalViewExamples 4 | // 5 | // Created by Guido Marucci Blas on 2/18/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import PortalView 11 | 12 | extension Array { 13 | 14 | func sample() -> Element { 15 | return self[Int(arc4random_uniform(UInt32(self.count)))] 16 | } 17 | 18 | } 19 | 20 | func tableCellComponent(index: Int, text: String, backgroundColor: Color, itemMaxHeight: UInt) -> Component { 21 | return container( 22 | children: [ 23 | label( 24 | text: "Text for cell \(index)", 25 | style: labelStyleSheet() { base, label in 26 | label.textColor = .white 27 | }, 28 | layout: layout() { 29 | $0.flex = flex() { 30 | $0.grow = .one 31 | } 32 | } 33 | ), 34 | label( 35 | text: text, 36 | style: labelStyleSheet() { base, label in 37 | label.textColor = .white 38 | label.numberOfLines = 3 39 | }, 40 | layout: layout() { 41 | $0.flex = flex() { 42 | $0.grow = .two 43 | } 44 | } 45 | ) 46 | ], 47 | style: styleSheet() { 48 | $0.backgroundColor = backgroundColor 49 | }, 50 | layout: layout() { 51 | $0.height = Dimension(maximum: itemMaxHeight) 52 | $0.flex = flex() { 53 | $0.direction = .column 54 | } 55 | } 56 | ) 57 | } 58 | 59 | func dynamicHeightTable() -> Component { 60 | 61 | let backgroundColors = [ 62 | Color.blue, 63 | Color.red, 64 | Color.green, 65 | Color.gray 66 | ] 67 | 68 | let texts = [ 69 | "This is a simple text", 70 | "This is a simple text but a little bit longer you know!", 71 | "This is a large text and it is going to be as large as I want it to be because that is how rad I am. Well it is not that large!" 72 | ] 73 | 74 | let content = (0 ... 20).map { ($0, texts.sample(), backgroundColors.sample()) } 75 | let items = content.map { index, text, backgroundColor in 76 | tableItem(height: 90) { 77 | TableItemRender( 78 | component: tableCellComponent( 79 | index: index, 80 | text: text, 81 | backgroundColor: backgroundColor, 82 | itemMaxHeight: $0 83 | ), 84 | typeIdentifier: "Cell" 85 | ) 86 | } 87 | } 88 | 89 | return table( 90 | items: items, 91 | style: tableStyleSheet() { base, table in 92 | table.separatorColor = .black 93 | }, 94 | layout: layout() { 95 | $0.flex = flex() { 96 | $0.grow = .one 97 | } 98 | } 99 | ) 100 | 101 | } 102 | -------------------------------------------------------------------------------- /Examples/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Guido Marucci Blas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | import PackageDescription 2 | 3 | let package = Package( 4 | name: "PortalView" 5 | ) 6 | -------------------------------------------------------------------------------- /PortalView.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | //: Playground - noun: a place where people can play 2 | 3 | import UIKit 4 | import PlaygroundSupport 5 | import PortalView 6 | 7 | let width = CGFloat(320) 8 | let height = CGFloat(568) 9 | let frame = CGRect(x: 0, y: 0, width: width, height: height) 10 | let root = UIView(frame: frame) 11 | root.backgroundColor = .black 12 | 13 | 14 | //PlaygroundPage.current.liveView = root 15 | -------------------------------------------------------------------------------- /PortalView.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /PortalView.playground/timeline.xctimeline: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /PortalView.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /PortalView.xcodeproj/xcshareddata/xcschemes/PortalView.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 | 75 | 76 | 82 | 83 | 89 | 90 | 91 | 92 | 94 | 95 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /PortalView.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PortalView 2 | ========== 3 | 4 | [![Swift](https://img.shields.io/badge/swift-3-orange.svg?style=flat)](#) 5 | [![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) 6 | [![Platform](https://img.shields.io/badge/platform-iOS-lightgrey.svg?style=flat)](#) 7 | [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://opensource.org/licenses/MIT) 8 | 9 | A (potentially) cross-platform, declarative and immutable Swift library for building user interfaces. 10 | 11 | **WARNING!: This is still a work-in-progress, although the minimum features are available to create real world applications the API is still under design and some key optimizations are still missing. Use at your own risk.** 12 | 13 | ## TL; DR; 14 | 15 | * Declarative API inspired by [Elm](http://elm-lang.org/) and [React](https://facebook.github.io/react/). 16 | * 100% in Swift and decoupled from UIKit which makes it (potentially) cross-platform. 17 | * Uses facebook's [Yoga](http://github.com/facebook/yoga). A cross-platform layout engine that implements [Flexbox](https://www.w3schools.com/CSS/css3_flexbox.asp) which is used by [ReactNative](https://github.com/facebook/react-native). 18 | * Leverage the Swift compiler in order to have a strongly type-safe API. 19 | 20 | Here is a sneak peak of the API but you can also check [this examples](https://github.com/guidomb/PortalView#example) or read the library [overview](./Documentation/Overview.md) to learn more about the main concepts. 21 | 22 | ```swift 23 | enum Message { 24 | 25 | case like 26 | case goToDetailScreen 27 | 28 | } 29 | 30 | let component: Component = container( 31 | children: [ 32 | label( 33 | text: "Hello PortalView!", 34 | style: labelStyleSheet() { base, label in 35 | base.backgroundColor = .white 36 | label.textColor = .red 37 | label.textSize = 12 38 | }, 39 | layout: layout() { 40 | $0.flex = flex() { 41 | $0.grow = .one 42 | } 43 | $0.justifyContent = .flexEnd 44 | } 45 | ) 46 | button( 47 | properties: properties() { 48 | $0.text = "Tap to like!" 49 | $0.onTap = .like 50 | } 51 | ) 52 | button( 53 | properties: properties() { 54 | $0.text = "Tap to got to detail screen" 55 | $0.onTap = .goToDetailScreen 56 | } 57 | ) 58 | ] 59 | ) 60 | ``` 61 | 62 | ## Installation 63 | 64 | ### Carthage 65 | 66 | Install [Carthage](https://github.com/Carthage/Carthage) first by either using the [official .pkg installer](https://github.com/Carthage/Carthage/releases) for the latest release or If you use [Homebrew](http://brew.sh) execute the following commands: 67 | 68 | ``` 69 | brew update 70 | brew install carthage 71 | ``` 72 | 73 | Once Carthage is installed add the following entry to your `Cartfile` 74 | 75 | ``` 76 | github "guidomb/PortalView" "master" 77 | ``` 78 | 79 | ### Manual 80 | 81 | TODO 82 | 83 | ## Example 84 | 85 | For some examples on how the API looks like and how to use this library check 86 | 87 | * The [examples](./Examples.xcodeproj) project in this repository. 88 | * [This](https://github.com/guidomb/SyrmoPortalExample) example project 89 | * The following video 90 | 91 | [![PortalView live reload example](https://img.youtube.com/vi/Xaj6vdNLC5k/0.jpg)](https://www.youtube.com/watch?v=Xaj6vdNLC5k) 92 | 93 | ## Documentation 94 | 95 | PortalView is still a work-in-progress. Documentation will be added as the library matures inside the [Documentation](./Documentation) directory. 96 | You can read the library [overview](./Documentation/Overview.md) to learn more about the main concepts. 97 | 98 | ## Contribute 99 | 100 | ### Setup 101 | 102 | Install [Carthage](https://github.com/Carthage/Carthage) first, then run 103 | 104 | ``` 105 | git clone git@github.com:guidomb/PortalView.git 106 | cd PortalView 107 | script/bootstrap 108 | open PortalView.xcworkspace 109 | ``` 110 | -------------------------------------------------------------------------------- /Sources/Component.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Component.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 1/27/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | public enum RootComponent { 12 | 13 | case simple 14 | case stack(NavigationBar) 15 | case tab(TabBar) 16 | 17 | } 18 | 19 | public enum SupportedOrientations { 20 | 21 | case portrait 22 | case landscape 23 | case all 24 | 25 | } 26 | 27 | public enum Gesture { 28 | 29 | case tap(message: MessageType) 30 | 31 | } 32 | 33 | public extension Gesture { 34 | 35 | public func map(_ transform: @escaping (MessageType) -> NewMessageType) -> Gesture { 36 | switch self { 37 | 38 | case .tap(let message): 39 | return .tap(message: transform(message)) 40 | 41 | } 42 | } 43 | 44 | } 45 | 46 | public indirect enum Component { 47 | 48 | case button(ButtonProperties, StyleSheet, Layout) 49 | case label(LabelProperties, StyleSheet, Layout) 50 | case mapView(MapProperties, StyleSheet, Layout) 51 | case imageView(Image, StyleSheet, Layout) 52 | case container([Component], StyleSheet, Layout) 53 | case table(TableProperties, StyleSheet, Layout) 54 | case collection(CollectionProperties, StyleSheet, Layout) 55 | case carousel(CarouselProperties, StyleSheet, Layout) 56 | case touchable(gesture: Gesture, child: Component) 57 | case segmented(ZipList>, StyleSheet, Layout) 58 | case progress(ProgressCounter, StyleSheet, Layout) 59 | case textField(TextFieldProperties, StyleSheet, Layout) 60 | case custom(componentIdentifier: String, layout: Layout) 61 | case spinner(Bool, StyleSheet, Layout) 62 | 63 | public var layout: Layout { 64 | switch self { 65 | 66 | case .button(_, _, let layout): 67 | return layout 68 | 69 | case .label(_, _, let layout): 70 | return layout 71 | 72 | case .textField(_, _, let layout): 73 | return layout 74 | 75 | case .mapView(_, _, let layout): 76 | return layout 77 | 78 | case .imageView(_, _, let layout): 79 | return layout 80 | 81 | case .container(_, _, let layout): 82 | return layout 83 | 84 | case .table(_, _, let layout): 85 | return layout 86 | 87 | case .touchable(_, let child): 88 | return child.layout 89 | 90 | case .segmented(_, _, let layout): 91 | return layout 92 | 93 | case .progress(_, _, let layout): 94 | return layout 95 | 96 | case .collection(_, _, let layout): 97 | return layout 98 | 99 | case .carousel(_, _, let layout): 100 | return layout 101 | 102 | case .custom(_, let layout): 103 | return layout 104 | 105 | case .spinner(_, _, let layout): 106 | return layout 107 | 108 | } 109 | } 110 | 111 | } 112 | 113 | extension Component { 114 | 115 | public func map(_ transform: @escaping (MessageType) -> NewMessageType) -> Component { 116 | switch self { 117 | 118 | case .button(let properties, let style, let layout): 119 | return .button(properties.map(transform), style, layout) 120 | 121 | case .label(let properties, let style, let layout): 122 | return .label(properties, style, layout) 123 | 124 | case .textField(let properties, let style, let layout): 125 | return .textField(properties.map(transform), style, layout) 126 | 127 | case .mapView(let properties, let style, let layout): 128 | return .mapView(properties, style, layout) 129 | 130 | case .imageView(let image, let style, let layout): 131 | return .imageView(image, style, layout) 132 | 133 | case .container(let children, let style, let layout): 134 | return .container(children.map { $0.map(transform) }, style, layout) 135 | 136 | case .table(let properties, let style, let layout): 137 | return .table(properties.map(transform), style, layout) 138 | 139 | case .touchable(let gesture, let child): 140 | return .touchable(gesture: gesture.map(transform), child: child.map(transform)) 141 | 142 | case .segmented(let segments, let style, let layout): 143 | return .segmented(segments.map { $0.map(transform) }, style, layout) 144 | 145 | case .progress(let progress, let style, let layout): 146 | return .progress(progress, style, layout) 147 | 148 | case .collection(let properties, let style, let layout): 149 | return .collection(properties.map(transform), style, layout) 150 | 151 | case .carousel(let properties, let style, let layout): 152 | return .carousel(properties.map(transform), style, layout) 153 | 154 | case .custom(let componentIdentifier, let layout): 155 | return .custom(componentIdentifier: componentIdentifier, layout: layout) 156 | 157 | case .spinner(let isActive, let style, let layout): 158 | return .spinner(isActive, style, layout) 159 | 160 | } 161 | } 162 | 163 | public var customComponentIdentifiers: [String] { 164 | switch self { 165 | 166 | case .container(let children, _, _): 167 | return children.flatMap { $0.customComponentIdentifiers } 168 | 169 | case .custom(let componentIdentifier, _): 170 | return [componentIdentifier] 171 | 172 | default: 173 | return [] 174 | 175 | } 176 | } 177 | 178 | } 179 | 180 | public func container( 181 | children: [Component] = [], 182 | style: StyleSheet = EmptyStyleSheet.`default`, 183 | layout: Layout = layout()) -> Component { 184 | return .container(children, style, layout) 185 | } 186 | 187 | public func touchable(gesture: Gesture, child: Component) -> Component { 188 | return .touchable(gesture: gesture, child: child) 189 | } 190 | -------------------------------------------------------------------------------- /Sources/Components/Button.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button.swift 3 | // Portal 4 | // 5 | // Created by Guido Marucci Blas on 12/18/16. 6 | // Copyright © 2016 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct ButtonProperties { 12 | 13 | public var text: String? 14 | public var isActive: Bool 15 | public var icon: Image? 16 | public var onTap: MessageType? 17 | 18 | fileprivate init( 19 | text: String? = .none, 20 | isActive: Bool = false, 21 | icon: Image? = .none, 22 | onTap: MessageType? = .none) { 23 | self.text = text 24 | self.isActive = isActive 25 | self.icon = icon 26 | self.onTap = onTap 27 | } 28 | 29 | } 30 | 31 | public extension ButtonProperties { 32 | 33 | public func map(_ transform: (MessageType) -> NewMessageType) -> ButtonProperties { 34 | return ButtonProperties( 35 | text: self.text, 36 | isActive: self.isActive, 37 | icon: self.icon, 38 | onTap: self.onTap.map(transform) 39 | ) 40 | } 41 | 42 | } 43 | 44 | public func button( 45 | text: String, 46 | onTap: MessageType, 47 | style: StyleSheet = ButtonStyleSheet.defaultStyleSheet, 48 | layout: Layout = layout()) -> Component { 49 | return .button(ButtonProperties(text: text, onTap: onTap), style, layout) 50 | } 51 | 52 | public func button( 53 | properties: ButtonProperties = ButtonProperties(), 54 | style: StyleSheet = ButtonStyleSheet.defaultStyleSheet, 55 | layout: Layout = layout()) -> Component { 56 | return .button(properties, style, layout) 57 | } 58 | 59 | public func properties(configure: (inout ButtonProperties) -> ()) -> ButtonProperties { 60 | var properties = ButtonProperties() 61 | configure(&properties) 62 | return properties 63 | } 64 | 65 | // MARK:- Style sheet 66 | 67 | public struct ButtonStyleSheet { 68 | 69 | public static let defaultStyleSheet = StyleSheet(component: ButtonStyleSheet()) 70 | 71 | public var textColor: Color 72 | public var textFont: Font 73 | public var textSize: UInt 74 | 75 | public init( 76 | textColor: Color = .black, 77 | textFont: Font = defaultFont, 78 | textSize: UInt = defaultButtonFontSize) { 79 | self.textColor = textColor 80 | self.textFont = textFont 81 | self.textSize = textSize 82 | } 83 | 84 | } 85 | 86 | public func buttonStyleSheet(configure: (inout BaseStyleSheet, inout ButtonStyleSheet) -> ()) -> StyleSheet { 87 | var base = BaseStyleSheet() 88 | var custom = ButtonStyleSheet() 89 | configure(&base, &custom) 90 | return StyleSheet(component: custom, base: base) 91 | } 92 | -------------------------------------------------------------------------------- /Sources/Components/Carousel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Carousel.swift 3 | // PortalView 4 | // 5 | // Created by Cristian Ames on 4/17/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | public enum ZipListShiftOperation { 13 | 14 | case left(count: UInt) 15 | case right(count: UInt) 16 | 17 | } 18 | 19 | public extension ZipList { 20 | 21 | func execute(shiftOperation: ZipListShiftOperation) -> ZipList? { 22 | switch shiftOperation { 23 | case .left(let count): 24 | return self.shiftLeft(count: count) 25 | case .right(let count): 26 | return self.shiftRight(count: count) 27 | } 28 | } 29 | 30 | } 31 | 32 | public struct CarouselProperties { 33 | 34 | public var items: ZipList>? 35 | public var showsScrollIndicator: Bool 36 | public var isSnapToCellEnabled: Bool 37 | public var onSelectionChange: (ZipListShiftOperation) -> MessageType? 38 | 39 | // Layout properties 40 | public var itemsWidth: UInt 41 | public var itemsHeight: UInt 42 | public var minimumInteritemSpacing: UInt 43 | public var minimumLineSpacing: UInt 44 | public var sectionInset: SectionInset 45 | 46 | fileprivate init( 47 | items: ZipList>?, 48 | showsScrollIndicator: Bool = false, 49 | isSnapToCellEnabled: Bool = false, 50 | itemsWidth: UInt, 51 | itemsHeight: UInt, 52 | minimumInteritemSpacing: UInt = 0, 53 | minimumLineSpacing: UInt = 0, 54 | sectionInset: SectionInset = .zero, 55 | selected: UInt = 0, 56 | onSelectionChange: @escaping (ZipListShiftOperation) -> MessageType? = { _ in .none }) { 57 | self.items = items 58 | self.showsScrollIndicator = showsScrollIndicator 59 | self.isSnapToCellEnabled = isSnapToCellEnabled 60 | self.itemsWidth = itemsWidth 61 | self.itemsHeight = itemsHeight 62 | self.minimumLineSpacing = minimumLineSpacing 63 | self.minimumInteritemSpacing = minimumInteritemSpacing 64 | self.sectionInset = sectionInset 65 | self.onSelectionChange = onSelectionChange 66 | } 67 | 68 | public func map(_ transform: @escaping (MessageType) -> NewMessageType) -> CarouselProperties { 69 | return CarouselProperties( 70 | items: self.items.map { $0.map { $0.map(transform) } }, 71 | showsScrollIndicator: self.showsScrollIndicator, 72 | isSnapToCellEnabled: self.isSnapToCellEnabled, 73 | itemsWidth: self.itemsWidth, 74 | itemsHeight: self.itemsHeight, 75 | minimumInteritemSpacing: self.minimumInteritemSpacing, 76 | minimumLineSpacing: self.minimumLineSpacing, 77 | sectionInset: self.sectionInset, 78 | onSelectionChange: { self.onSelectionChange($0).map(transform) } 79 | ) 80 | } 81 | 82 | } 83 | 84 | public struct CarouselItemProperties { 85 | 86 | public typealias Renderer = () -> Component 87 | 88 | public let onTap: MessageType? 89 | public let renderer: Renderer 90 | public let identifier: String 91 | 92 | fileprivate init( 93 | onTap: MessageType?, 94 | identifier: String, 95 | renderer: @escaping Renderer) { 96 | self.onTap = onTap 97 | self.renderer = renderer 98 | self.identifier = identifier 99 | } 100 | 101 | } 102 | 103 | extension CarouselItemProperties { 104 | 105 | public func map(_ transform: @escaping (MessageType) -> NewMessageType) -> CarouselItemProperties { 106 | 107 | return CarouselItemProperties( 108 | onTap: self.onTap.map(transform), 109 | identifier: self.identifier, 110 | renderer: { self.renderer().map(transform) } 111 | ) 112 | } 113 | 114 | } 115 | 116 | public func carousel( 117 | properties: CarouselProperties, 118 | style: StyleSheet = EmptyStyleSheet.default, 119 | layout: Layout = layout()) -> Component { 120 | return .carousel(properties, style, layout) 121 | } 122 | 123 | public func carouselItem( 124 | onTap: MessageType? = .none, 125 | identifier: String, 126 | renderer: @escaping CarouselItemProperties.Renderer) -> CarouselItemProperties { 127 | return CarouselItemProperties(onTap: onTap, identifier: identifier, renderer: renderer) 128 | } 129 | 130 | public func properties(itemsWidth: UInt, itemsHeight: UInt, items: ZipList>?, configure: (inout CarouselProperties) -> ()) -> CarouselProperties { 131 | var properties = CarouselProperties(items: items, itemsWidth: itemsWidth, itemsHeight: itemsHeight) 132 | configure(&properties) 133 | return properties 134 | } 135 | -------------------------------------------------------------------------------- /Sources/Components/Collection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionView.swift 3 | // PortalView 4 | // 5 | // Created by Argentino Ducret on 4/4/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | public class SectionInset { 13 | public static let zero = SectionInset(top: 0, left: 0, bottom: 0, right: 0) 14 | 15 | public var bottom: UInt 16 | public var top: UInt 17 | public var left: UInt 18 | public var right: UInt 19 | 20 | public init(top: UInt, left: UInt, bottom: UInt, right: UInt) { 21 | self.bottom = bottom 22 | self.top = top 23 | self.left = left 24 | self.right = right 25 | } 26 | 27 | } 28 | 29 | public enum CollectionScrollDirection { 30 | case horizontal 31 | case vertical 32 | } 33 | 34 | public struct CollectionProperties { 35 | 36 | public var items: [CollectionItemProperties] 37 | public var showsVerticalScrollIndicator: Bool 38 | public var showsHorizontalScrollIndicator: Bool 39 | 40 | // Layout properties 41 | public var itemsWidth: UInt 42 | public var itemsHeight: UInt 43 | public var minimumInteritemSpacing: UInt 44 | public var minimumLineSpacing: UInt 45 | public var scrollDirection: CollectionScrollDirection 46 | public var sectionInset: SectionInset 47 | 48 | fileprivate init( 49 | items: [CollectionItemProperties] = [], 50 | showsVerticalScrollIndicator: Bool = false, 51 | showsHorizontalScrollIndicator: Bool = false, 52 | itemsWidth: UInt, 53 | itemsHeight: UInt, 54 | minimumInteritemSpacing: UInt = 0, 55 | minimumLineSpacing: UInt = 0, 56 | scrollDirection: CollectionScrollDirection = .vertical, 57 | sectionInset: SectionInset = .zero) { 58 | self.items = items 59 | self.showsVerticalScrollIndicator = showsVerticalScrollIndicator 60 | self.showsHorizontalScrollIndicator = showsHorizontalScrollIndicator 61 | self.itemsWidth = itemsWidth 62 | self.itemsHeight = itemsHeight 63 | self.minimumLineSpacing = minimumLineSpacing 64 | self.minimumInteritemSpacing = minimumInteritemSpacing 65 | self.sectionInset = sectionInset 66 | self.scrollDirection = scrollDirection 67 | } 68 | 69 | public func map(_ transform: @escaping (MessageType) -> NewMessageType) -> CollectionProperties { 70 | return CollectionProperties( 71 | items: self.items.map { $0.map(transform) }, 72 | showsVerticalScrollIndicator: self.showsVerticalScrollIndicator, 73 | showsHorizontalScrollIndicator: self.showsHorizontalScrollIndicator, 74 | itemsWidth: self.itemsWidth, 75 | itemsHeight: self.itemsHeight, 76 | minimumInteritemSpacing: self.minimumInteritemSpacing, 77 | minimumLineSpacing: self.minimumLineSpacing, 78 | scrollDirection: self.scrollDirection, 79 | sectionInset: self.sectionInset) 80 | } 81 | 82 | } 83 | 84 | public struct CollectionItemProperties { 85 | 86 | public typealias Renderer = () -> Component 87 | 88 | public let onTap: MessageType? 89 | public let renderer: Renderer 90 | public let identifier: String 91 | 92 | fileprivate init( 93 | onTap: MessageType?, 94 | identifier: String, 95 | renderer: @escaping Renderer) { 96 | self.onTap = onTap 97 | self.renderer = renderer 98 | self.identifier = identifier 99 | } 100 | 101 | } 102 | 103 | extension CollectionItemProperties { 104 | 105 | public func map(_ transform: @escaping (MessageType) -> NewMessageType) -> CollectionItemProperties { 106 | return CollectionItemProperties( 107 | onTap: self.onTap.map(transform), 108 | identifier: self.identifier, 109 | renderer: { self.renderer().map(transform) } 110 | ) 111 | } 112 | 113 | } 114 | 115 | public func collection( 116 | properties: CollectionProperties, 117 | style: StyleSheet = EmptyStyleSheet.default, 118 | layout: Layout = layout()) -> Component { 119 | return .collection(properties, style, layout) 120 | } 121 | 122 | public func collectionItem( 123 | onTap: MessageType? = .none, 124 | identifier: String, 125 | renderer: @escaping CollectionItemProperties.Renderer) -> CollectionItemProperties { 126 | return CollectionItemProperties(onTap: onTap, identifier: identifier, renderer: renderer) 127 | } 128 | 129 | public func properties(itemsWidth: UInt, itemsHeight: UInt, configure: (inout CollectionProperties) -> ()) -> CollectionProperties { 130 | var properties = CollectionProperties(itemsWidth: itemsWidth, itemsHeight: itemsHeight) 131 | configure(&properties) 132 | return properties 133 | } 134 | -------------------------------------------------------------------------------- /Sources/Components/Image.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Image.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 1/27/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | public struct Size { 12 | 13 | public var width: UInt 14 | public var height: UInt 15 | 16 | } 17 | 18 | public protocol ImageType { 19 | 20 | var size: Size { get } 21 | 22 | } 23 | 24 | public func imageView( 25 | image: Image, 26 | style: StyleSheet = EmptyStyleSheet.`default`, 27 | layout: Layout = layout()) -> Component { 28 | return .imageView(image, style, layout) 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Components/Label.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Label.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 1/27/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | public struct LabelProperties { 12 | 13 | public let text: String 14 | public let textAfterLayout: String? 15 | 16 | fileprivate init(text: String, textAfterLayout: String?) { 17 | self.text = text 18 | self.textAfterLayout = textAfterLayout 19 | } 20 | 21 | } 22 | 23 | public func label( 24 | text: String = "", 25 | style: StyleSheet = LabelStyleSheet.`default`, 26 | layout: Layout = layout()) -> Component { 27 | return .label(properties(text: text), style, layout) 28 | } 29 | 30 | public func label( 31 | properties: LabelProperties = properties(), 32 | style: StyleSheet = LabelStyleSheet.`default`, 33 | layout: Layout = layout()) -> Component { 34 | return .label(properties, style, layout) 35 | } 36 | 37 | public func properties(text: String = "", textAfterLayout: String? = .none) -> LabelProperties { 38 | return LabelProperties(text: text, textAfterLayout: textAfterLayout) 39 | } 40 | 41 | // MARK: - Style sheet 42 | 43 | public struct LabelStyleSheet { 44 | 45 | static let `default` = StyleSheet(component: LabelStyleSheet()) 46 | 47 | public var textColor: Color 48 | public var textFont: Font 49 | public var textSize: UInt 50 | public var textAligment: TextAligment 51 | public var adjustToFitWidth: Bool 52 | public var numberOfLines: UInt 53 | public var minimumScaleFactor: Float 54 | 55 | public init( 56 | textColor: Color = .black, 57 | textFont: Font = defaultFont, 58 | textSize: UInt = defaultButtonFontSize, 59 | textAligment: TextAligment = .natural, 60 | adjustToFitWidth: Bool = false, 61 | numberOfLines: UInt = 0, 62 | minimumScaleFactor: Float = 0) { 63 | self.textColor = textColor 64 | self.textFont = textFont 65 | self.textSize = textSize 66 | self.textAligment = textAligment 67 | self.adjustToFitWidth = adjustToFitWidth 68 | self.numberOfLines = numberOfLines 69 | self.minimumScaleFactor = minimumScaleFactor 70 | } 71 | 72 | } 73 | 74 | public func labelStyleSheet(configure: (inout BaseStyleSheet, inout LabelStyleSheet) -> () = { _ in }) -> StyleSheet { 75 | var base = BaseStyleSheet() 76 | var component = LabelStyleSheet() 77 | configure(&base, &component) 78 | return StyleSheet(component: component, base: base) 79 | } 80 | -------------------------------------------------------------------------------- /Sources/Components/MapView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MapView.swift 3 | // Portal 4 | // 5 | // Created by Guido Marucci Blas on 12/19/16. 6 | // Copyright © 2016 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct Coordinates { 12 | 13 | public var latitude: Double 14 | public var longitude: Double 15 | 16 | public init(latitude: Double, longitude: Double) { 17 | self.latitude = latitude 18 | self.longitude = longitude 19 | } 20 | 21 | } 22 | 23 | public struct MapPlacemark { 24 | 25 | public let coordinates: Coordinates 26 | public let icon: Image? 27 | 28 | public init(coordinates: Coordinates, icon: Image? = .none) { 29 | self.coordinates = coordinates 30 | self.icon = icon 31 | } 32 | 33 | } 34 | 35 | public struct MapProperties { 36 | 37 | public var placemarks: [MapPlacemark] 38 | public var center: Coordinates? 39 | public var isZoomEnabled: Bool 40 | public var zoomLevel: Double 41 | public var isScrollEnabled: Bool 42 | 43 | public init( 44 | placemarks: [MapPlacemark] = [], 45 | center: Coordinates? = .none, 46 | isZoomEnabled: Bool = true, 47 | zoomLevel: Double = 1.0, 48 | isScrollEnabled: Bool = true) { 49 | self.placemarks = placemarks 50 | self.isZoomEnabled = isZoomEnabled 51 | self.zoomLevel = zoomLevel 52 | self.center = center 53 | self.isScrollEnabled = isScrollEnabled 54 | } 55 | 56 | } 57 | 58 | public func mapView( 59 | properties: MapProperties = MapProperties(), 60 | style: StyleSheet = EmptyStyleSheet.`default`, 61 | layout: Layout = layout()) -> Component { 62 | return .mapView(properties, style, layout) 63 | } 64 | 65 | public func mapView( 66 | placemarks: [MapPlacemark] = [], 67 | style: StyleSheet = EmptyStyleSheet.`default`, 68 | layout: Layout = layout()) -> Component { 69 | return .mapView(MapProperties(placemarks: placemarks), style, layout) 70 | } 71 | 72 | public func properties(configure: (inout MapProperties) -> ()) -> MapProperties { 73 | var properties = MapProperties() 74 | configure(&properties) 75 | return properties 76 | } 77 | -------------------------------------------------------------------------------- /Sources/Components/NavigationBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationBar.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 1/27/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | public enum NavigationBarTitle { 12 | 13 | case text(String) 14 | case image(Image) 15 | case component(Component) 16 | 17 | } 18 | 19 | public enum NavigationBarButton { 20 | 21 | case textButton(title: String, onTap: MessageType) 22 | case imageButton(icon: Image, onTap: MessageType) 23 | 24 | } 25 | 26 | public struct NavigationBarProperties { 27 | 28 | public var title: NavigationBarTitle? 29 | public var hideBackButtonTitle: Bool 30 | public var onBack: MessageType? 31 | public var leftButtonItems: [NavigationBarButton]? 32 | public var rightButtonItems: [NavigationBarButton]? 33 | 34 | fileprivate init( 35 | title: NavigationBarTitle? = .none, 36 | hideBackButtonTitle: Bool = false, 37 | onBack: MessageType? = .none, 38 | leftButtonItems: [NavigationBarButton]? = .none, 39 | rightButtonItems: [NavigationBarButton]? = .none) { 40 | self.title = title 41 | self.hideBackButtonTitle = hideBackButtonTitle 42 | self.onBack = onBack 43 | self.leftButtonItems = leftButtonItems 44 | self.rightButtonItems = rightButtonItems 45 | } 46 | 47 | } 48 | 49 | public struct NavigationBar { 50 | 51 | public let properties: NavigationBarProperties 52 | public let style: StyleSheet 53 | 54 | fileprivate init(properties: NavigationBarProperties, style: StyleSheet) { 55 | self.properties = properties 56 | self.style = style 57 | } 58 | 59 | } 60 | 61 | public func navigationBar( 62 | properties: NavigationBarProperties, 63 | style: StyleSheet = navigationBarStyleSheet()) -> NavigationBar { 64 | return NavigationBar(properties: properties, style: style) 65 | } 66 | 67 | public func navigationBar( 68 | title: String, 69 | onBack: MessageType, 70 | style: StyleSheet = navigationBarStyleSheet()) -> NavigationBar { 71 | return NavigationBar( 72 | properties: properties() { 73 | $0.title = .text(title) 74 | $0.onBack = onBack 75 | }, 76 | style: style 77 | ) 78 | } 79 | 80 | public func navigationBar( 81 | title: Image, 82 | onBack: MessageType, 83 | style: StyleSheet = navigationBarStyleSheet()) -> NavigationBar { 84 | return NavigationBar( 85 | properties: properties() { 86 | $0.title = .image(title) 87 | $0.onBack = onBack 88 | }, 89 | style: style 90 | ) 91 | } 92 | 93 | public func properties(configure: (inout NavigationBarProperties) -> ()) -> NavigationBarProperties { 94 | var properties = NavigationBarProperties() 95 | configure(&properties) 96 | return properties 97 | } 98 | 99 | // MARK: - Style sheet 100 | 101 | public let defaultNavigationBarTitleFontSize: UInt = 17 102 | 103 | public struct NavigationBarStyleSheet { 104 | 105 | public static let `default` = StyleSheet(component: NavigationBarStyleSheet()) 106 | 107 | public var tintColor: Color 108 | public var titleTextColor: Color 109 | public var titleTextFont: Font 110 | public var titleTextSize: UInt 111 | public var isTranslucent: Bool 112 | public var statusBarStyle: StatusBarStyle 113 | 114 | fileprivate init( 115 | tintColor: Color = .black, 116 | titleTextColor: Color = .black, 117 | titleTextFont: Font = defaultFont, 118 | titleTextSize: UInt = defaultNavigationBarTitleFontSize, 119 | isTranslucent: Bool = true, 120 | statusBarStyle: StatusBarStyle = .`default`) { 121 | self.tintColor = tintColor 122 | self.titleTextFont = titleTextFont 123 | self.titleTextColor = titleTextColor 124 | self.titleTextSize = titleTextSize 125 | self.isTranslucent = isTranslucent 126 | self.statusBarStyle = statusBarStyle 127 | } 128 | 129 | } 130 | 131 | public func navigationBarStyleSheet(configure: (inout BaseStyleSheet, inout NavigationBarStyleSheet) -> () = { _ in }) -> StyleSheet { 132 | var base = BaseStyleSheet() 133 | var component = NavigationBarStyleSheet() 134 | configure(&base, &component) 135 | return StyleSheet(component: component, base: base) 136 | } 137 | -------------------------------------------------------------------------------- /Sources/Components/Progress.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Progress.swift 3 | // PortalView 4 | // 5 | // Created by Cristian Ames on 4/11/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | public func progress( 10 | progress: ProgressCounter = ProgressCounter.initial, 11 | style: StyleSheet = ProgressStyleSheet.defaultStyleSheet, 12 | layout: Layout = layout()) -> Component { 13 | return .progress(progress, style, layout) 14 | } 15 | 16 | // MARK:- Style sheet 17 | 18 | public enum ProgressContentType { 19 | 20 | case color(Color) 21 | case image(Image) 22 | 23 | } 24 | 25 | public struct ProgressStyleSheet { 26 | 27 | public static let defaultStyleSheet = StyleSheet(component: ProgressStyleSheet()) 28 | 29 | public var progressStyle: ProgressContentType 30 | public var trackStyle: ProgressContentType 31 | 32 | public init( 33 | progressStyle: ProgressContentType = .color(defaultProgressColor), 34 | trackStyle: ProgressContentType = .color(defaultTrackColor)) { 35 | self.progressStyle = progressStyle 36 | self.trackStyle = trackStyle 37 | } 38 | 39 | } 40 | 41 | public func progressStyleSheet(configure: (inout BaseStyleSheet, inout ProgressStyleSheet) -> ()) -> StyleSheet { 42 | var base = BaseStyleSheet() 43 | var custom = ProgressStyleSheet() 44 | configure(&base, &custom) 45 | return StyleSheet(component: custom, base: base) 46 | } 47 | -------------------------------------------------------------------------------- /Sources/Components/Segmented.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Segmented.swift 3 | // PortalView 4 | // 5 | // Created by Cristian Ames on 4/4/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public enum SegmentContentType { 12 | 13 | case title(String) 14 | case image(Image) 15 | 16 | } 17 | 18 | public struct SegmentProperties { 19 | 20 | public let content: SegmentContentType 21 | public let onTap: MessageType? 22 | public let isEnabled: Bool 23 | 24 | fileprivate init( 25 | content: SegmentContentType, 26 | onTap: MessageType? = .none, 27 | isEnabled: Bool = true) { 28 | self.content = content 29 | self.onTap = onTap 30 | self.isEnabled = isEnabled 31 | } 32 | 33 | } 34 | 35 | extension SegmentProperties { 36 | 37 | public func map(_ transform: (MessageType) -> NewMessageType) -> SegmentProperties { 38 | return SegmentProperties(content: content, onTap: onTap.map(transform), isEnabled: isEnabled) 39 | } 40 | 41 | } 42 | 43 | public func segmented( 44 | segments: ZipList>, 45 | style: StyleSheet = SegmentedStyleSheet.default, 46 | layout: Layout = layout()) -> Component { 47 | return .segmented(segments, style, layout) 48 | } 49 | 50 | public func segment( 51 | title: String, 52 | onTap: MessageType? = .none, 53 | isEnabled: Bool = true) -> SegmentProperties { 54 | return SegmentProperties(content: .title(title), onTap: onTap, isEnabled: isEnabled) 55 | } 56 | 57 | public func segment( 58 | image: Image, 59 | onTap: MessageType? = .none, 60 | isEnabled: Bool = true) -> SegmentProperties { 61 | return SegmentProperties(content: .image(image), onTap: onTap, isEnabled: isEnabled) 62 | } 63 | 64 | // MARK:- Style sheet 65 | 66 | public struct SegmentedStyleSheet { 67 | 68 | public static let `default` = StyleSheet(component: SegmentedStyleSheet()) 69 | 70 | public var textFont: Font 71 | public var textSize: UInt 72 | public var textColor: Color 73 | public var borderColor: Color 74 | 75 | public init( 76 | textFont: Font = defaultFont, 77 | textSize: UInt = defaultButtonFontSize, 78 | textColor: Color = .blue, 79 | borderColor: Color = .blue) { 80 | self.textFont = textFont 81 | self.textSize = textSize 82 | self.textColor = textColor 83 | self.borderColor = borderColor 84 | } 85 | 86 | } 87 | 88 | public func segmentedStyleSheet(configure: (inout BaseStyleSheet, inout SegmentedStyleSheet) -> ()) -> StyleSheet { 89 | var base = BaseStyleSheet() 90 | var custom = SegmentedStyleSheet() 91 | configure(&base, &custom) 92 | return StyleSheet(component: custom, base: base) 93 | } 94 | -------------------------------------------------------------------------------- /Sources/Components/Spinner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Spinner.swift 3 | // PortalView 4 | // 5 | // Created by Cristian Ames on 4/21/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | public func spinner( 10 | isActive: Bool = false, 11 | style: StyleSheet = SpinnerStyleSheet.defaultStyleSheet, 12 | layout: Layout = layout()) -> Component { 13 | return .spinner(isActive, style, layout) 14 | } 15 | 16 | public struct SpinnerStyleSheet { 17 | 18 | public static let defaultStyleSheet = StyleSheet(component: SpinnerStyleSheet()) 19 | 20 | public var color: Color 21 | 22 | public init( 23 | color: Color = .black) { 24 | self.color = color 25 | } 26 | 27 | } 28 | 29 | public func spinnerStyleSheet(configure: (inout BaseStyleSheet, inout SpinnerStyleSheet) -> ()) -> StyleSheet { 30 | var base = BaseStyleSheet() 31 | var custom = SpinnerStyleSheet() 32 | configure(&base, &custom) 33 | return StyleSheet(component: custom, base: base) 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Components/Table.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Table.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 1/27/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | public enum TableItemSelectionStyle { 12 | 13 | case none 14 | case `default` 15 | case blue 16 | case gray 17 | 18 | } 19 | 20 | public struct TableProperties { 21 | 22 | public var items: [TableItemProperties] 23 | public var showsVerticalScrollIndicator: Bool 24 | public var showsHorizontalScrollIndicator: Bool 25 | 26 | fileprivate init( 27 | items: [TableItemProperties] = [], 28 | showsVerticalScrollIndicator: Bool = true, 29 | showsHorizontalScrollIndicator: Bool = true) { 30 | self.items = items 31 | self.showsHorizontalScrollIndicator = showsHorizontalScrollIndicator 32 | self.showsVerticalScrollIndicator = showsVerticalScrollIndicator 33 | } 34 | 35 | public func map(_ transform: @escaping (MessageType) -> NewMessageType) -> TableProperties { 36 | return TableProperties(items: self.items.map { $0.map(transform) }) 37 | } 38 | 39 | } 40 | 41 | public struct TableItemProperties { 42 | 43 | public typealias Renderer = (UInt) -> TableItemRender 44 | 45 | public let height: UInt 46 | public let onTap: MessageType? 47 | public let selectionStyle: TableItemSelectionStyle 48 | public let renderer: Renderer 49 | 50 | fileprivate init( 51 | height: UInt, 52 | onTap: MessageType?, 53 | selectionStyle: TableItemSelectionStyle, 54 | renderer: @escaping Renderer) { 55 | self.height = height 56 | self.onTap = onTap 57 | self.selectionStyle = selectionStyle 58 | self.renderer = renderer 59 | } 60 | 61 | } 62 | 63 | extension TableItemProperties { 64 | 65 | public func map(_ transform: @escaping (MessageType) -> NewMessageType) -> TableItemProperties { 66 | return TableItemProperties( 67 | height: height, 68 | onTap: self.onTap.map(transform), 69 | selectionStyle: selectionStyle, 70 | renderer: { self.renderer($0).map(transform) } 71 | ) 72 | } 73 | 74 | } 75 | 76 | public struct TableItemRender { 77 | 78 | public let component: Component 79 | public let typeIdentifier: String 80 | 81 | public init(component: Component, typeIdentifier: String) { 82 | self.component = component 83 | self.typeIdentifier = typeIdentifier 84 | } 85 | 86 | } 87 | 88 | extension TableItemRender { 89 | 90 | public func map(_ transform: @escaping (MessageType) -> NewMessageType) -> TableItemRender { 91 | return TableItemRender( 92 | component: self.component.map(transform), 93 | typeIdentifier: self.typeIdentifier 94 | ) 95 | } 96 | 97 | } 98 | 99 | public func table( 100 | properties: TableProperties = TableProperties(), 101 | style: StyleSheet = TableStyleSheet.default, 102 | layout: Layout = layout()) -> Component { 103 | return .table(properties, style, layout) 104 | } 105 | 106 | public func table( 107 | items: [TableItemProperties] = [], 108 | style: StyleSheet = TableStyleSheet.default, 109 | layout: Layout = layout()) -> Component { 110 | return .table(TableProperties(items: items), style, layout) 111 | } 112 | 113 | public func tableItem( 114 | height: UInt, 115 | onTap: MessageType? = .none, 116 | selectionStyle: TableItemSelectionStyle = .`default`, 117 | renderer: @escaping TableItemProperties.Renderer) -> TableItemProperties { 118 | return TableItemProperties(height: height, onTap: onTap, selectionStyle: selectionStyle, renderer: renderer) 119 | } 120 | 121 | public func properties(configure: (inout TableProperties) -> ()) -> TableProperties { 122 | var properties = TableProperties() 123 | configure(&properties) 124 | return properties 125 | } 126 | 127 | // MARK: - Style sheet 128 | 129 | public struct TableStyleSheet { 130 | 131 | public static let `default` = StyleSheet(component: TableStyleSheet()) 132 | 133 | public var separatorColor: Color 134 | 135 | fileprivate init(separatorColor: Color = Color.clear) { 136 | self.separatorColor = separatorColor 137 | } 138 | 139 | } 140 | 141 | public func tableStyleSheet(configure: (inout BaseStyleSheet, inout TableStyleSheet) -> () = { _ in }) -> StyleSheet { 142 | var base = BaseStyleSheet() 143 | var component = TableStyleSheet() 144 | configure(&base, &component) 145 | return StyleSheet(component: component, base: base) 146 | } 147 | -------------------------------------------------------------------------------- /Sources/Components/TextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextField.swift 3 | // PortalView 4 | // 5 | // Created by Juan Franco Caracciolo on 4/10/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct TextFieldProperties { 12 | 13 | public var text: String? 14 | public var placeholder: String? 15 | public var onEvents: TextFieldEvents 16 | 17 | fileprivate init( 18 | text: String? = .none, 19 | placeholder: String? = .none, 20 | onEvents: TextFieldEvents = TextFieldEvents() ) { 21 | self.text = text 22 | self.placeholder = placeholder 23 | self.onEvents = onEvents 24 | } 25 | 26 | public func map(_ transform: (MessageType) -> NewMessageType) -> TextFieldProperties { 27 | return TextFieldProperties( 28 | text: self.text, 29 | placeholder: self.placeholder, 30 | onEvents: self.onEvents.map(transform) 31 | ) 32 | } 33 | 34 | } 35 | 36 | public struct TextFieldEvents { 37 | 38 | public var onEditingBegin: MessageType? 39 | public var onEditingChanged: MessageType? 40 | public var onEditingEnd: MessageType? 41 | 42 | public init( 43 | onEditingBegin: MessageType? = .none, 44 | onEditingChanged: MessageType? = .none, 45 | onEditingEnd: MessageType? = .none) { 46 | self.onEditingBegin = onEditingBegin 47 | self.onEditingChanged = onEditingChanged 48 | self.onEditingEnd = onEditingEnd 49 | } 50 | 51 | public func map(_ transform: (MessageType) -> NewMessageType) -> TextFieldEvents { 52 | return TextFieldEvents( 53 | onEditingBegin: onEditingBegin.map(transform), 54 | onEditingChanged: onEditingChanged.map(transform), 55 | onEditingEnd: onEditingEnd.map(transform) 56 | ) 57 | } 58 | 59 | } 60 | 61 | public func textFieldEvents(configure: (inout TextFieldEvents) -> ()) -> TextFieldEvents { 62 | var events = TextFieldEvents() 63 | configure(&events) 64 | return events 65 | } 66 | 67 | public func textField( 68 | properties: TextFieldProperties = TextFieldProperties(), 69 | style: StyleSheet = TextFieldStyleSheet.`default`, 70 | layout: Layout = layout()) -> Component { 71 | return .textField(properties, style, layout) 72 | } 73 | 74 | public func properties(configure: (inout TextFieldProperties) -> ()) -> TextFieldProperties { 75 | var properties = TextFieldProperties() 76 | configure(&properties) 77 | return properties 78 | } 79 | 80 | // MARK: - Style sheet 81 | 82 | public struct TextFieldStyleSheet { 83 | 84 | static let `default` = StyleSheet(component: TextFieldStyleSheet()) 85 | 86 | public var textColor: Color 87 | public var textFont: Font 88 | public var textSize: UInt 89 | public var textAligment: TextAligment 90 | public init( 91 | textColor: Color = .black, 92 | textFont: Font = defaultFont, 93 | textSize: UInt = defaultButtonFontSize, 94 | textAligment: TextAligment = .natural ) { 95 | self.textColor = textColor 96 | self.textFont = textFont 97 | self.textSize = textSize 98 | self.textAligment = textAligment 99 | } 100 | 101 | } 102 | 103 | public func textFieldStyleSheet(configure: (inout BaseStyleSheet, inout TextFieldStyleSheet) -> () = { _ in }) -> StyleSheet { 104 | var base = BaseStyleSheet() 105 | var component = TextFieldStyleSheet() 106 | configure(&base, &component) 107 | return StyleSheet(component: component, base: base) 108 | } 109 | -------------------------------------------------------------------------------- /Sources/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // Portal 4 | // 5 | // Created by Guido Marucci Blas on 12/16/16. 6 | // Copyright © 2016 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | precedencegroup ForwardApplication { 12 | /// Associates to the left so that pipeline behaves as expected. 13 | associativity: left 14 | higherThan: AssignmentPrecedence 15 | } 16 | 17 | infix operator |>: ForwardApplication 18 | 19 | internal func |>(lhs: A?, rhs: (A) -> ()) { 20 | lhs.apply(function: rhs) 21 | } 22 | 23 | extension Optional { 24 | 25 | internal func apply(function: (Wrapped) -> ()) { 26 | if let value = self { 27 | function(value) 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 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 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSPrincipalClass 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Sources/Layout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Layout.swift 3 | // Portal 4 | // 5 | // Created by Guido Marucci Blas on 12/10/16. 6 | // Copyright © 2016 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum FlexDirection { 12 | 13 | case column 14 | case row 15 | case columnReverse 16 | case rowReverse 17 | 18 | } 19 | 20 | public enum JustifyContent { 21 | 22 | case flexStart 23 | case flexEnd 24 | case center 25 | case spaceBetween 26 | case spaceAround 27 | 28 | } 29 | 30 | public enum FlexWrap { 31 | 32 | case nowrap 33 | case wrap 34 | 35 | } 36 | 37 | public enum AlignItems { 38 | 39 | case stretch 40 | case flexStart 41 | case flexEnd 42 | case center 43 | 44 | } 45 | 46 | public enum AlignSelf { 47 | 48 | case stretch 49 | case flexStart 50 | case flexEnd 51 | case center 52 | 53 | } 54 | 55 | public enum AlignContent { 56 | 57 | case flexStart 58 | case flexEnd 59 | case center 60 | case stretch 61 | case spaceAround 62 | 63 | } 64 | 65 | public enum Margin { 66 | 67 | case all(value: UInt) 68 | case by(edge: Edge) 69 | 70 | } 71 | 72 | public enum Border { 73 | 74 | case all(value: UInt) 75 | case by(edge: Edge) 76 | 77 | } 78 | 79 | public enum Padding { 80 | 81 | case all(value: UInt) 82 | case by(edge: Edge) 83 | 84 | } 85 | 86 | public enum Direction { 87 | 88 | case inherit 89 | case leftToRight 90 | case rightToLeft 91 | 92 | } 93 | 94 | public struct Edge { 95 | 96 | public var left: UInt? 97 | public var top: UInt? 98 | public var right: UInt? 99 | public var bottom: UInt? 100 | public var start: UInt? 101 | public var end: UInt? 102 | public var horizontal: UInt? 103 | public var vertical: UInt? 104 | 105 | public init( 106 | left: UInt? = .none, 107 | top: UInt? = .none, 108 | right: UInt? = .none, 109 | bottom: UInt? = .none, 110 | start: UInt? = .none, 111 | end: UInt? = .none, 112 | horizontal: UInt? = .none, 113 | vertical: UInt? = .none 114 | ) { 115 | self.left = left 116 | self.top = top 117 | self.right = right 118 | self.bottom = bottom 119 | self.start = start 120 | self.end = end 121 | self.start = start 122 | self.end = end 123 | self.horizontal = horizontal 124 | self.vertical = vertical 125 | } 126 | 127 | } 128 | 129 | public enum Position { 130 | 131 | case relative 132 | case absolute(forEdge: Edge) 133 | 134 | } 135 | 136 | public struct Alignment { 137 | 138 | public var content: AlignContent 139 | public var `self`: AlignSelf? 140 | public var items: AlignItems 141 | 142 | public init(content: AlignContent = .flexStart, `self` alignSelf: AlignSelf? = .none, items: AlignItems = .stretch) { 143 | self.content = content 144 | self.`self` = alignSelf 145 | self.items = items 146 | } 147 | 148 | } 149 | 150 | public struct FlexValue: RawRepresentable { 151 | 152 | public var rawValue: Double 153 | 154 | public static var zero = FlexValue(rawValue: 0)! 155 | public static var one = FlexValue(rawValue: 1)! 156 | public static var two = FlexValue(rawValue: 2)! 157 | public static var three = FlexValue(rawValue: 3)! 158 | public static var four = FlexValue(rawValue: 4)! 159 | public static var five = FlexValue(rawValue: 5)! 160 | public static var six = FlexValue(rawValue: 6)! 161 | public static var seven = FlexValue(rawValue: 7)! 162 | public static var eight = FlexValue(rawValue: 8)! 163 | public static var nine = FlexValue(rawValue: 9)! 164 | public static var ten = FlexValue(rawValue: 10)! 165 | 166 | public init?(rawValue: Double) { 167 | guard rawValue >= 0 else { return nil } 168 | self.rawValue = rawValue 169 | } 170 | 171 | } 172 | 173 | public struct Flex { 174 | 175 | public var direction: FlexDirection 176 | public var grow: FlexValue 177 | public var shrink: FlexValue 178 | public var wrap: FlexWrap 179 | public var basis: UInt? 180 | 181 | public init( 182 | direction: FlexDirection = .column, 183 | grow: FlexValue = .zero, 184 | shrink: FlexValue = .zero, 185 | wrap: FlexWrap = .nowrap, 186 | basis: UInt? = .none) { 187 | 188 | self.direction = direction 189 | self.grow = grow 190 | self.shrink = shrink 191 | self.wrap = wrap 192 | self.basis = basis 193 | } 194 | 195 | } 196 | 197 | public struct Dimension { 198 | 199 | public var minimum: UInt? 200 | public var maximum: UInt? 201 | public var value: UInt? 202 | 203 | public init(value: UInt? = .none, minimum: UInt? = .none, maximum: UInt? = .none) { 204 | self.value = value 205 | self.minimum = minimum 206 | self.maximum = maximum 207 | } 208 | 209 | 210 | } 211 | 212 | public struct AspectRatio: RawRepresentable { 213 | 214 | public var rawValue: Double 215 | 216 | public init?(rawValue: Double) { 217 | guard rawValue > 0 else { return nil } 218 | self.rawValue = rawValue 219 | } 220 | 221 | } 222 | 223 | public struct Layout { 224 | 225 | public var flex: Flex 226 | public var justifyContent: JustifyContent 227 | public var width: Dimension? 228 | public var height: Dimension? 229 | public var alignment: Alignment 230 | public var position: Position 231 | public var margin: Margin? 232 | public var padding: Padding? 233 | public var border: Border? 234 | public var aspectRatio: AspectRatio? 235 | public var direction: Direction 236 | 237 | fileprivate init( 238 | flex: Flex = Flex(), 239 | justifyContent: JustifyContent = .flexStart, 240 | width: Dimension? = .none, 241 | height: Dimension? = .none, 242 | alignment: Alignment = Alignment(), 243 | position: Position = .relative, 244 | margin: Margin? = .none, 245 | padding: Padding? = .none, 246 | border: Border? = .none, 247 | aspectRatio: AspectRatio? = .none, 248 | direction: Direction = .inherit 249 | ) { 250 | self.flex = flex 251 | self.justifyContent = justifyContent 252 | self.width = width 253 | self.height = height 254 | self.alignment = alignment 255 | self.position = position 256 | self.margin = margin 257 | self.padding = padding 258 | self.border = border 259 | self.aspectRatio = aspectRatio 260 | self.direction = direction 261 | } 262 | 263 | } 264 | 265 | public func layout(configure: (inout Layout) -> () = { _ in }) -> Layout { 266 | var object = Layout() 267 | configure(&object) 268 | return object 269 | } 270 | 271 | public func flex(configure: (inout Flex) -> () = { _ in }) -> Flex { 272 | var object = Flex() 273 | configure(&object) 274 | return object 275 | } 276 | 277 | public func dimension(configure: (inout Dimension) -> () = { _ in }) -> Dimension { 278 | var object = Dimension() 279 | configure(&object) 280 | return object 281 | } 282 | 283 | public func aligment(configure: (inout Alignment) -> () = { _ in }) -> Alignment { 284 | var object = Alignment() 285 | configure(&object) 286 | return object 287 | } 288 | 289 | public func edge(configure: (inout Edge) -> () = { _ in }) -> Edge { 290 | var object = Edge() 291 | configure(&object) 292 | return object 293 | } 294 | -------------------------------------------------------------------------------- /Sources/LayoutEngine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LayoutEngine.swift 3 | // Portal 4 | // 5 | // Created by Guido Marucci Blas on 12/11/16. 6 | // Copyright © 2016 Guido Marucci Blas. All rights reserved. 7 | // 8 | import UIKit 9 | import YogaKit 10 | 11 | public protocol LayoutEngine { 12 | 13 | func layout(view: UIView, inside container: UIView) 14 | 15 | func apply(layout: Layout, to view: UIView) 16 | 17 | } 18 | 19 | internal struct YogaLayoutEngine : LayoutEngine { 20 | 21 | func layout(view: UIView, inside container: UIView) { 22 | container.yoga.isEnabled = true 23 | container.yoga.width = container.bounds.size.width 24 | container.yoga.height = container.bounds.size.height 25 | container.addSubview(view) 26 | container.yoga.applyLayout(preservingOrigin: true) 27 | } 28 | 29 | func apply(layout: Layout, to view: UIView) { 30 | view.yoga.isEnabled = true 31 | 32 | apply(flex: layout.flex, to: view) 33 | apply(justifyContent: layout.justifyContent, to: view) 34 | apply(alignment: layout.alignment, to: view) 35 | apply(position: layout.position, to: view) 36 | apply(direction: layout.direction, to: view) 37 | 38 | layout.width |> { apply(width: $0, to: view) } 39 | layout.height |> { apply(height: $0, to: view) } 40 | layout.margin |> { apply(margin: $0, to: view) } 41 | layout.padding |> { apply(padding: $0, to: view) } 42 | layout.border |> { apply(border: $0, to: view) } 43 | layout.aspectRatio |> { apply(aspectRation: $0, to: view) } 44 | } 45 | 46 | } 47 | 48 | fileprivate extension YogaLayoutEngine { 49 | 50 | fileprivate func apply(flex: Flex, to view: UIView) { 51 | view.yoga.flexDirection = flex.direction.yg_flexDirection 52 | view.yoga.flexGrow = CGFloat(flex.grow.rawValue) 53 | view.yoga.flexShrink = CGFloat(flex.shrink.rawValue) 54 | view.yoga.flexWrap = flex.wrap.yg_flexWrap 55 | flex.basis |> { view.yoga.flexBasis = CGFloat($0) } 56 | } 57 | 58 | fileprivate func apply(justifyContent: JustifyContent, to view: UIView) { 59 | view.yoga.justifyContent = justifyContent.yg_justifyContent 60 | } 61 | 62 | fileprivate func apply(width: Dimension, to view: UIView) { 63 | width.value |> { view.yoga.width = CGFloat($0) } 64 | width.minimum |> { view.yoga.minWidth = CGFloat($0) } 65 | width.maximum |> { view.yoga.maxWidth = CGFloat($0) } 66 | } 67 | 68 | fileprivate func apply(height: Dimension, to view: UIView) { 69 | height.value |> { view.yoga.height = CGFloat($0) } 70 | height.minimum |> { view.yoga.minHeight = CGFloat($0) } 71 | height.maximum |> { view.yoga.maxHeight = CGFloat($0) } 72 | } 73 | 74 | fileprivate func apply(alignment: Alignment, to view: UIView) { 75 | view.yoga.alignContent = alignment.content.yg_alignContent 76 | view.yoga.alignItems = alignment.items.yg_alignItems 77 | alignment.`self` |> { view.yoga.alignSelf = $0.yg_alignSelf } 78 | } 79 | 80 | fileprivate func apply(position: Position, to view: UIView) { 81 | switch position { 82 | 83 | case .absolute(let edge): 84 | view.yoga.position = .absolute 85 | edge.left |> { view.yoga.left = CGFloat($0) } 86 | edge.right |> { view.yoga.right = CGFloat($0) } 87 | edge.top |> { view.yoga.top = CGFloat($0) } 88 | edge.bottom |> { view.yoga.bottom = CGFloat($0) } 89 | edge.start |> { view.yoga.start = CGFloat($0) } 90 | edge.end |> { view.yoga.end = CGFloat($0) } 91 | 92 | // TODO review why this properties are missing 93 | // edge.horizontal |> { view.yoga.horizontal = CGFloat($0) } 94 | // edge.vertical |> { view.yoga.vertical = CGFloat($0) } 95 | 96 | case .relative: 97 | view.yoga.position = .relative 98 | } 99 | } 100 | 101 | fileprivate func apply(margin: Margin, to view: UIView) { 102 | switch margin { 103 | 104 | case .all(let value): 105 | view.yoga.margin = CGFloat(value) 106 | 107 | case .by(let edge): 108 | edge.left |> { view.yoga.marginLeft = CGFloat($0) } 109 | edge.right |> { view.yoga.marginRight = CGFloat($0) } 110 | edge.top |> { view.yoga.marginTop = CGFloat($0) } 111 | edge.bottom |> { view.yoga.marginBottom = CGFloat($0) } 112 | edge.start |> { view.yoga.marginStart = CGFloat($0) } 113 | edge.end |> { view.yoga.marginEnd = CGFloat($0) } 114 | edge.horizontal |> { view.yoga.marginHorizontal = CGFloat($0) } 115 | edge.vertical |> { view.yoga.marginVertical = CGFloat($0) } 116 | 117 | } 118 | } 119 | 120 | fileprivate func apply(padding: Padding, to view: UIView) { 121 | switch padding { 122 | 123 | case .all(let value): 124 | view.yoga.padding = CGFloat(value) 125 | 126 | case .by(let edge): 127 | edge.left |> { view.yoga.paddingLeft = CGFloat($0) } 128 | edge.right |> { view.yoga.paddingRight = CGFloat($0) } 129 | edge.top |> { view.yoga.paddingTop = CGFloat($0) } 130 | edge.bottom |> { view.yoga.paddingBottom = CGFloat($0) } 131 | edge.start |> { view.yoga.paddingStart = CGFloat($0) } 132 | edge.end |> { view.yoga.paddingEnd = CGFloat($0) } 133 | edge.horizontal |> { view.yoga.paddingHorizontal = CGFloat($0) } 134 | edge.vertical |> { view.yoga.paddingVertical = CGFloat($0) } 135 | } 136 | } 137 | 138 | fileprivate func apply(border: Border, to view: UIView) { 139 | switch border { 140 | 141 | case .all(let value): 142 | view.yoga.borderWidth = CGFloat(value) 143 | 144 | case .by(let edge): 145 | edge.left |> { view.yoga.borderLeftWidth = CGFloat($0) } 146 | edge.right |> { view.yoga.borderRightWidth = CGFloat($0) } 147 | edge.top |> { view.yoga.borderTopWidth = CGFloat($0) } 148 | edge.bottom |> { view.yoga.borderBottomWidth = CGFloat($0) } 149 | edge.start |> { view.yoga.borderStartWidth = CGFloat($0) } 150 | edge.end |> { view.yoga.borderEndWidth = CGFloat($0) } 151 | 152 | // TODO review why this properties are missing 153 | // edge.horizontal |> { view.yoga.borderHorizontal = CGFloat($0) } 154 | // edge.vertical |> { view.yoga.borderVertical = CGFloat($0) } 155 | } 156 | } 157 | 158 | fileprivate func apply(aspectRation: AspectRatio, to view: UIView) { 159 | view.yoga.aspectRatio = CGFloat(aspectRation.rawValue) 160 | } 161 | 162 | fileprivate func apply(direction: Direction, to view: UIView) { 163 | view.yoga.direction = direction.yg_direction 164 | } 165 | 166 | } 167 | 168 | fileprivate extension AlignContent { 169 | 170 | fileprivate var yg_alignContent: YGAlign { 171 | // TODO review this. It seems there is a mismatch betweeen docs 172 | switch self { 173 | case .flexStart: 174 | return .flexStart 175 | case .flexEnd: 176 | return .flexEnd 177 | case .center: 178 | return .center 179 | case .stretch: 180 | return .stretch 181 | case .spaceAround: 182 | return .auto 183 | } 184 | } 185 | 186 | } 187 | 188 | fileprivate extension AlignSelf { 189 | 190 | fileprivate var yg_alignSelf: YGAlign { 191 | switch self { 192 | case .stretch: 193 | return .stretch 194 | case .flexStart: 195 | return .flexStart 196 | case .flexEnd: 197 | return .flexEnd 198 | case .center: 199 | return .center 200 | } 201 | } 202 | 203 | } 204 | 205 | fileprivate extension AlignItems { 206 | 207 | fileprivate var yg_alignItems: YGAlign { 208 | switch self { 209 | case .stretch: 210 | return .stretch 211 | case .flexStart: 212 | return .flexStart 213 | case .flexEnd: 214 | return .flexEnd 215 | case .center: 216 | return .center 217 | } 218 | } 219 | 220 | } 221 | 222 | fileprivate extension FlexDirection { 223 | 224 | fileprivate var yg_flexDirection: YGFlexDirection { 225 | switch self { 226 | case .row: 227 | return .row 228 | case .rowReverse: 229 | return .rowReverse 230 | case .column: 231 | return .column 232 | case .columnReverse: 233 | return .columnReverse 234 | } 235 | } 236 | 237 | } 238 | 239 | fileprivate extension FlexWrap { 240 | 241 | fileprivate var yg_flexWrap: YGWrap { 242 | switch self { 243 | case .nowrap: 244 | return .noWrap 245 | case .wrap: 246 | return .wrap 247 | } 248 | } 249 | 250 | } 251 | 252 | fileprivate extension JustifyContent { 253 | 254 | fileprivate var yg_justifyContent: YGJustify { 255 | switch self { 256 | case .flexStart: 257 | return .flexStart 258 | case .flexEnd: 259 | return .flexEnd 260 | case .center: 261 | return .center 262 | case .spaceAround: 263 | return .spaceAround 264 | case .spaceBetween: 265 | return .spaceBetween 266 | } 267 | } 268 | 269 | } 270 | 271 | fileprivate extension Direction { 272 | 273 | fileprivate var yg_direction: YGDirection { 274 | switch self { 275 | case .inherit: 276 | return .inherit 277 | case .leftToRight: 278 | return .LTR 279 | case .rightToLeft: 280 | return .RTL 281 | } 282 | } 283 | 284 | } 285 | -------------------------------------------------------------------------------- /Sources/Mailbox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Mailbox.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 1/27/17. 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | // This type needs to have reference semantics 12 | // due to how container components are rendered. 13 | // 14 | // All child components' mailboxes of a container component 15 | // are forwared to the container's mailbox thus the need to 16 | // have a single mailbox reference. Keep in mind that a Mailbox 17 | // is a mutable type. Subscribers can be added anytime thus the need 18 | // for a reference type because any object that gets a reference to 19 | // a mailbox should be able to send a message to all its subscribers 20 | // no matter when subscribers where added to the mailbox. 21 | // 22 | // See how the `forward` method is implemented. 23 | public final class Mailbox { 24 | 25 | fileprivate var subscribers: [(MessageType) -> ()] = [] 26 | 27 | public func subscribe(subscriber: @escaping (MessageType) -> ()) { 28 | subscribers.append(subscriber) 29 | } 30 | 31 | } 32 | 33 | extension Mailbox { 34 | 35 | internal func dispatch(message: MessageType) { 36 | subscribers.forEach { $0(message) } 37 | } 38 | 39 | internal func forward(to mailbox: Mailbox) { 40 | subscribe { mailbox.dispatch(message: $0) } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Portal.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View.swift 3 | // Portal 4 | // 5 | // Created by Guido Marucci Blas on 12/9/16. 6 | // Copyright © 2016 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol Font { 12 | 13 | var name: String { get } 14 | 15 | } 16 | 17 | public struct TabBar { 18 | 19 | } 20 | 21 | 22 | public protocol Renderer { 23 | 24 | associatedtype MessageType 25 | 26 | var isDebugModeEnabled: Bool { get set } 27 | 28 | func render(component: Component) -> Mailbox 29 | 30 | } 31 | -------------------------------------------------------------------------------- /Sources/ProgressCounter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressCounter.swift 3 | // PortalView 4 | // 5 | // Created by Cristian Ames on 4/11/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | public struct ProgressCounter { 10 | 11 | public static let initial = ProgressCounter() 12 | 13 | public var partial: UInt 14 | public let total: UInt 15 | public var progress: Float { 16 | return Float(partial) / Float(total) 17 | } 18 | public var remaining: UInt { 19 | return total - partial 20 | } 21 | 22 | private init() { 23 | partial = 0 24 | total = 1 25 | } 26 | 27 | public init?(partial: UInt, total: UInt) { 28 | guard partial <= total else { return nil } 29 | 30 | self.partial = partial 31 | self.total = total 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Sources/StyleSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StyleSheet.swift 3 | // Portal 4 | // 5 | // Created by Guido Marucci Blas on 12/13/16. 6 | // Copyright © 2016 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct Color { 12 | 13 | public static var red: Color { 14 | return Color(hex: 0xFF0000)! 15 | } 16 | 17 | public static var green: Color { 18 | return Color(hex: 0x00FF00)! 19 | } 20 | 21 | public static var blue: Color { 22 | return Color(hex: 0x0000FF)! 23 | } 24 | 25 | public static var yellow: Color { 26 | return Color(hex: 0xFFFF00)! 27 | } 28 | 29 | public static var black: Color { 30 | return Color(hex: 0x000000)! 31 | } 32 | 33 | public static var white: Color { 34 | return Color(hex: 0xFFFFFF)! 35 | } 36 | 37 | public static var gray: Color { 38 | return Color(hex: 0x808080)! 39 | } 40 | 41 | public static var clear: Color { 42 | return Color(red: 0.0, green: 0.0, blue: 0.0, alpha: 0.0)! 43 | } 44 | 45 | public let red: Float 46 | public let green: Float 47 | public let blue: Float 48 | public let alpha: Float 49 | 50 | public init?(red: Float, green: Float, blue: Float, alpha: Float = 1.0) { 51 | guard alpha >= 0.0 && alpha <= 1.0 else { return nil } 52 | 53 | self.red = red 54 | self.green = green 55 | self.blue = blue 56 | self.alpha = alpha 57 | } 58 | 59 | public init?(red: Int, green: Int, blue: Int) { 60 | guard red >= 0 && red <= 255, 61 | green >= 0 && green <= 255, 62 | blue >= 0 && blue <= 255 63 | else { return nil } 64 | 65 | self.init(red: Float(red) / 255.0, green: Float(green) / 255.0, blue: Float(blue) / 255.0, alpha: 1.0) 66 | } 67 | 68 | public init?(hex:Int) { 69 | self.init(red:(hex >> 16) & 0xff, green:(hex >> 8) & 0xff, blue:hex & 0xff) 70 | } 71 | 72 | } 73 | 74 | 75 | public struct StyleSheet { 76 | 77 | public var base: BaseStyleSheet 78 | public var component: ComponentStyleSheet 79 | 80 | internal init(component: ComponentStyleSheet, base: BaseStyleSheet = BaseStyleSheet()) { 81 | self.component = component 82 | self.base = base 83 | } 84 | 85 | } 86 | 87 | public struct BaseStyleSheet { 88 | 89 | public var backgroundColor: Color 90 | public var cornerRadius: Float? 91 | public var borderColor: Color 92 | public var borderWidth: Float 93 | public var alpha: Float 94 | 95 | public init( 96 | backgroundColor: Color = .clear, 97 | cornerRadius: Float? = .none, 98 | borderColor: Color = .clear, 99 | borderWidth: Float = 0.0, 100 | alpha: Float = 1.0 101 | ) { 102 | self.backgroundColor = backgroundColor 103 | self.cornerRadius = cornerRadius 104 | self.borderColor = borderColor 105 | self.borderWidth = borderWidth 106 | self.alpha = alpha 107 | } 108 | 109 | } 110 | 111 | public enum TextAligment { 112 | 113 | case left 114 | case center 115 | case right 116 | case justified 117 | case natural 118 | 119 | } 120 | 121 | public struct EmptyStyleSheet { 122 | 123 | static let `default` = StyleSheet(component: EmptyStyleSheet()) 124 | 125 | } 126 | 127 | public enum StatusBarStyle { 128 | 129 | case `default` 130 | case lightContent 131 | 132 | } 133 | 134 | public func styleSheet(configure: (inout BaseStyleSheet) -> () = { _ in }) -> StyleSheet { 135 | var base = BaseStyleSheet() 136 | configure(&base) 137 | return StyleSheet(component: EmptyStyleSheet(), base: base) 138 | } 139 | -------------------------------------------------------------------------------- /Sources/UIKit/MessageDispatcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjcMessageDispatcher.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | internal final class MessageDispatcher: NSObject { 12 | 13 | internal let mailbox: Mailbox 14 | internal let sender2Message: (Any) -> MessageType? 15 | 16 | init(mailbox: Mailbox = Mailbox(), message: MessageType) { 17 | self.mailbox = mailbox 18 | self.sender2Message = { _ in message } 19 | } 20 | 21 | init(mailbox: Mailbox = Mailbox(), sender2Message: @escaping (Any) -> MessageType?) { 22 | self.mailbox = mailbox 23 | self.sender2Message = sender2Message 24 | } 25 | 26 | @objc 27 | internal func dispatch(sender: Any) { 28 | guard let message = sender2Message(sender) else { return } 29 | mailbox.dispatch(message: message) 30 | } 31 | 32 | } 33 | 34 | extension MessageDispatcher { 35 | 36 | var selector: Selector { 37 | return #selector(MessageDispatcher.dispatch) 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Sources/UIKit/PortalCarouselView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PortalCarouselView.swift 3 | // PortalView 4 | // 5 | // Created by Cristian Ames on 4/17/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | import UIKit 9 | 10 | public final class PortalCarouselView: PortalCollectionView 11 | where CustomComponentRendererType.MessageType == MessageType { 12 | 13 | public var isSnapToCellEnabled: Bool = false 14 | 15 | fileprivate let onSelectionChange: (ZipListShiftOperation) -> MessageType? 16 | fileprivate var lastOffset: CGFloat = 0 17 | fileprivate var selectedIndex: Int = 0 18 | 19 | public override init(items: [CollectionItemProperties], layoutEngine: LayoutEngine, layout: UICollectionViewLayout, rendererFactory: @escaping CustomComponentRendererFactory) { 20 | onSelectionChange = { _ in .none } 21 | super.init( 22 | items: items, 23 | layoutEngine: layoutEngine, 24 | layout: layout, 25 | rendererFactory: rendererFactory 26 | ) 27 | } 28 | 29 | public init(items: ZipList>?, layoutEngine: LayoutEngine, layout: UICollectionViewLayout, rendererFactory: @escaping CustomComponentRendererFactory, onSelectionChange: @escaping (ZipListShiftOperation) -> MessageType?) { 30 | if let items = items { 31 | let transform = { (item: CarouselItemProperties) -> CollectionItemProperties in 32 | return collectionItem( 33 | onTap: item.onTap, 34 | identifier: item.identifier, 35 | renderer: item.renderer) 36 | } 37 | selectedIndex = Int(items.centerIndex) 38 | self.onSelectionChange = onSelectionChange 39 | super.init(items: items.map(transform), layoutEngine: layoutEngine, layout: layout, rendererFactory: rendererFactory) 40 | scrollToItem(self.selectedIndex, animated: false) 41 | } else { 42 | self.onSelectionChange = onSelectionChange 43 | super.init(items: [], layoutEngine: layoutEngine, layout: layout, rendererFactory: rendererFactory) 44 | } 45 | 46 | } 47 | 48 | required public init?(coder aDecoder: NSCoder) { 49 | fatalError("init(coder:) has not been implemented") 50 | } 51 | 52 | public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { 53 | lastOffset = scrollView.contentOffset.x 54 | } 55 | 56 | public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, 57 | targetContentOffset: UnsafeMutablePointer) { 58 | 59 | // At this moment we only support the message feature with the snap mode on. 60 | // TODO: Add support for messaging regardless the snap mode. 61 | // To do this feature we should detect the item selected not by adding or 62 | // supressing one to the index but searching the active item in the screen 63 | // at that moment. We could use `indexPathForItemAtPoint` for this purpose. 64 | guard isSnapToCellEnabled else { return } 65 | 66 | let currentOffset = CGFloat(scrollView.contentOffset.x) 67 | 68 | if currentOffset == lastOffset { 69 | return 70 | } 71 | 72 | let lastPosition = selectedIndex 73 | if currentOffset > lastOffset { 74 | if lastPosition < items.count - 1 { 75 | selectedIndex = lastPosition + 1 76 | scrollToItem(selectedIndex, animated: true) // Move to the right 77 | onSelectionChange(.left(count: 1)) |> { mailbox.dispatch(message: $0) } 78 | } 79 | } else if currentOffset < lastOffset { 80 | if lastPosition >= 1 { 81 | selectedIndex = lastPosition - 1 82 | scrollToItem(selectedIndex, animated: true) // Move to the left 83 | onSelectionChange(.right(count: 1)) |> { mailbox.dispatch(message: $0) } 84 | } 85 | } 86 | } 87 | 88 | } 89 | 90 | fileprivate extension PortalCarouselView { 91 | 92 | fileprivate func scrollToItem(_ position: Int, animated: Bool) { 93 | DispatchQueue.main.async { 94 | let indexPath = IndexPath(item: position, section: 0) 95 | self.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: animated) 96 | } 97 | } 98 | 99 | fileprivate func shiftDirection(actual: Int, old: Int) -> ZipListShiftOperation? { 100 | if actual < old { 101 | return ZipListShiftOperation.left(count: UInt(old - actual)) 102 | } else if actual > old { 103 | return ZipListShiftOperation.right(count: UInt(actual - old)) 104 | } else { 105 | return .none 106 | } 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /Sources/UIKit/PortalCollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PortalCollectionView.swift 3 | // PortalView 4 | // 5 | // Created by Argentino Ducret on 4/4/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public class PortalCollectionView: UICollectionView, UICollectionViewDataSource, UICollectionViewDelegate 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | public typealias CustomComponentRendererFactory = () -> CustomComponentRendererType 15 | 16 | public let mailbox = Mailbox() 17 | public var isDebugModeEnabled: Bool = false 18 | 19 | let layoutEngine: LayoutEngine 20 | let items: [CollectionItemProperties] 21 | let rendererFactory: CustomComponentRendererFactory 22 | 23 | public init(items: [CollectionItemProperties], layoutEngine: LayoutEngine, layout: UICollectionViewLayout, rendererFactory: @escaping CustomComponentRendererFactory) { 24 | self.items = items 25 | self.layoutEngine = layoutEngine 26 | self.rendererFactory = rendererFactory 27 | super.init(frame: .zero, collectionViewLayout: layout) 28 | 29 | self.dataSource = self 30 | self.delegate = self 31 | 32 | let identifiers = Set(items.map { $0.identifier }) 33 | identifiers.forEach { register(PortalCollectionViewCell.self, forCellWithReuseIdentifier: $0) } 34 | } 35 | 36 | required public init?(coder aDecoder: NSCoder) { 37 | fatalError("init(coder:) has not been implemented") 38 | } 39 | 40 | public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 41 | return items.count 42 | } 43 | 44 | public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 45 | let item = items[indexPath.row] 46 | if let cell = dequeueReusableCell(with: item.identifier, for: indexPath) { 47 | cell.component = itemRender(at: indexPath) 48 | cell.isDebugModeEnabled = isDebugModeEnabled 49 | cell.render(layoutEngine: layoutEngine, rendererFactory: rendererFactory) 50 | return cell 51 | } else { 52 | return UICollectionViewCell() 53 | } 54 | } 55 | 56 | public func numberOfSections(in collectionView: UICollectionView) -> Int { 57 | return 1 58 | } 59 | 60 | public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 61 | let item = items[indexPath.row] 62 | item.onTap |> { mailbox.dispatch(message: $0) } 63 | } 64 | 65 | } 66 | 67 | fileprivate extension PortalCollectionView { 68 | 69 | fileprivate func dequeueReusableCell(with identifier: String, for indexPath: IndexPath) -> PortalCollectionViewCell? { 70 | if let cell = dequeueReusableCell(withReuseIdentifier: identifier, for: indexPath) as? PortalCollectionViewCell { 71 | cell.forward(to: mailbox) 72 | return cell 73 | } else { 74 | return .none 75 | } 76 | } 77 | 78 | fileprivate func itemRender(at indexPath: IndexPath) -> Component { 79 | // TODO cache the result of calling renderer. Once the diff algorithm is implemented find a way to only 80 | // replace items that have changed. 81 | // IGListKit uses some library or algorithm to diff array. Maybe that can be used to make the array diff 82 | // more efficient. 83 | // 84 | // https://github.com/Instagram/IGListKit 85 | // 86 | // Check the video of the talk that presents IGListKit to find the array diff algorithm. 87 | // Also there is Dwifft which seems to be based in the same algorithm: 88 | // 89 | // https://github.com/jflinter/Dwifft 90 | // 91 | let item = items[indexPath.row] 92 | return item.renderer() 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /Sources/UIKit/PortalCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PortalCollectionViewCell.swift 3 | // PortalView 4 | // 5 | // Created by Argentino Ducret on 4/4/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public final class PortalCollectionViewCell: UICollectionViewCell 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | public typealias CustomComponentRendererFactory = () -> CustomComponentRendererType 15 | 16 | public var component: Component? = .none 17 | public var isDebugModeEnabled: Bool { 18 | set { 19 | self.renderer?.isDebugModeEnabled = newValue 20 | } 21 | get { 22 | return self.renderer?.isDebugModeEnabled ?? false 23 | } 24 | } 25 | 26 | fileprivate var renderer: UIKitComponentRenderer? = .none 27 | 28 | private let mailbox = Mailbox() 29 | private var mailboxForwarded = false 30 | 31 | public override init(frame: CGRect) { 32 | super.init(frame: frame) 33 | } 34 | 35 | public required init?(coder aDecoder: NSCoder) { 36 | fatalError("init(coder:) has not been implemented") 37 | } 38 | 39 | public func render(layoutEngine: LayoutEngine, rendererFactory: @escaping CustomComponentRendererFactory) { 40 | // TODO check if we need to do something about after layout hooks 41 | // TODO improve rendering performance by avoiding allocations. 42 | // Renderers should be able to reuse view objects instead of having 43 | // to allocate new ones if possible. 44 | if renderer == nil { 45 | renderer = UIKitComponentRenderer( 46 | containerView: contentView, 47 | layoutEngine: layoutEngine, 48 | rendererFactory: rendererFactory 49 | ) 50 | } 51 | 52 | if let component = self.component, let componentMailbox = renderer?.render(component: component) { 53 | componentMailbox.forward(to: mailbox) 54 | } 55 | } 56 | 57 | public func forward(to mailbox: Mailbox) { 58 | guard !mailboxForwarded else { return } 59 | 60 | self.mailbox.forward(to: mailbox) 61 | mailboxForwarded = true 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Sources/UIKit/PortalMapView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MapView.swift 3 | // Portal 4 | // 5 | // Created by Guido Marucci Blas on 12/18/16. 6 | // Copyright © 2016 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import MapKit 11 | 12 | fileprivate let PortalMapViewAnnotationIdentifier = "PortalMapViewAnnotation" 13 | 14 | internal final class PortalMapView: MKMapView { 15 | 16 | fileprivate var placemarks: [MapPlacemarkAnnotation : MapPlacemark] = [:] 17 | 18 | init(placemarks: [MapPlacemark]) { 19 | super.init(frame: CGRect.zero) 20 | for placemark in placemarks { 21 | let annotation = MapPlacemarkAnnotation(placemark: placemark) 22 | self.addAnnotation(annotation) 23 | self.placemarks[annotation] = placemark 24 | } 25 | self.delegate = self 26 | } 27 | 28 | required init?(coder aDecoder: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | } 33 | 34 | extension PortalMapView: MKMapViewDelegate { 35 | 36 | public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { 37 | guard 38 | let annotation = annotation as? MapPlacemarkAnnotation, 39 | let placemark = placemark(for: annotation) 40 | else { return .none } 41 | 42 | let annotationView = dequeueReusableAnnotationView(for: annotation) 43 | annotationView.image = placemark.icon?.asUIImage 44 | 45 | return annotationView 46 | } 47 | 48 | } 49 | 50 | extension PortalMapView { 51 | 52 | fileprivate func placemark(for annotation: MapPlacemarkAnnotation) -> MapPlacemark? { 53 | return placemarks[annotation] 54 | } 55 | 56 | fileprivate func dequeueReusableAnnotationView(for annotation: MapPlacemarkAnnotation) -> MKAnnotationView { 57 | if let annotationView = dequeueReusableAnnotationView(withIdentifier: PortalMapViewAnnotationIdentifier) { 58 | return annotationView 59 | } else { 60 | return MKAnnotationView(annotation: annotation, reuseIdentifier: PortalMapViewAnnotationIdentifier) 61 | } 62 | } 63 | 64 | } 65 | 66 | fileprivate final class MapPlacemarkAnnotation: NSObject, MKAnnotation { 67 | 68 | let coordinate: CLLocationCoordinate2D 69 | 70 | init(placemark: MapPlacemark) { 71 | self.coordinate = CLLocationCoordinate2D( 72 | latitude: placemark.coordinates.latitude, 73 | longitude: placemark.coordinates.longitude 74 | ) 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /Sources/UIKit/PortalNavigationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PortalNavigationController.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public final class PortalNavigationController: UINavigationController, UINavigationControllerDelegate 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | public typealias CustomComponentRendererFactory = (ContainerController) -> CustomComponentRendererType 15 | 16 | public let mailbox = Mailbox() 17 | public var isDebugModeEnabled: Bool = false 18 | public var orientation: SupportedOrientations = .all 19 | 20 | public var topController: PortalViewController? { 21 | return self.topViewController as? PortalViewController 22 | } 23 | 24 | public private(set) var isPopingTopController = false 25 | 26 | fileprivate let layoutEngine: LayoutEngine 27 | fileprivate let rendererFactory: CustomComponentRendererFactory 28 | fileprivate var disposers: [String : () -> Void] = [:] 29 | 30 | private let statusBarStyle: UIStatusBarStyle 31 | private var pushingViewController = false 32 | private var currentNavigationBarOnBack: MessageType? = .none 33 | private var onControllerDidShow: (() -> Void)? = .none 34 | private var onPop: (() -> Void)? = .none 35 | 36 | public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { 37 | switch orientation { 38 | case .all: 39 | return .all 40 | case .landscape: 41 | return .landscape 42 | case .portrait: 43 | return .portrait 44 | } 45 | } 46 | 47 | public override var preferredStatusBarStyle: UIStatusBarStyle { 48 | return statusBarStyle 49 | } 50 | 51 | init(layoutEngine: LayoutEngine, statusBarStyle: UIStatusBarStyle = .`default`, rendererFactory: @escaping CustomComponentRendererFactory) { 52 | self.rendererFactory = rendererFactory 53 | self.statusBarStyle = statusBarStyle 54 | self.layoutEngine = layoutEngine 55 | super.init(nibName: nil, bundle: nil) 56 | self.delegate = self 57 | } 58 | 59 | required public init?(coder aDecoder: NSCoder) { 60 | fatalError("init(coder:) has not been implemented") 61 | } 62 | 63 | deinit { 64 | disposers.values.forEach { $0() } 65 | } 66 | 67 | public func push(controller: PortalViewController, 68 | with navigationBar: NavigationBar, animated: Bool, completion: @escaping () -> Void) { 69 | pushingViewController = true 70 | onControllerDidShow = completion 71 | pushViewController(controller, animated: animated) 72 | render(navigationBar: navigationBar, inside: controller.navigationItem) 73 | controller.mailbox.forward(to: mailbox) 74 | } 75 | 76 | public func popTopController(completion: @escaping () -> Void) { 77 | onPop = completion 78 | popViewController(animated: true) 79 | } 80 | 81 | public func render(navigationBar: NavigationBar, inside navigationItem: UINavigationItem) { 82 | currentNavigationBarOnBack = navigationBar.properties.onBack 83 | self.navigationBar.apply(style: navigationBar.style) 84 | 85 | if let leftButtonItems = navigationBar.properties.leftButtonItems { 86 | navigationItem.leftBarButtonItems = leftButtonItems.map(render) 87 | } else if navigationBar.properties.hideBackButtonTitle { 88 | navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) 89 | } 90 | navigationItem.rightBarButtonItems = navigationBar.properties.rightButtonItems.map { $0.map(render) } 91 | 92 | 93 | if let title = navigationBar.properties.title { 94 | let renderer = NavigationBarTitleRenderer( 95 | navigationBarTitle: title, 96 | navigationItem: navigationItem, 97 | navigationBarSize: self.navigationBar.bounds.size, 98 | rendererFactory: { self.rendererFactory(self) } 99 | ) 100 | renderer.render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) |> { $0.forward(to: mailbox) } 101 | } 102 | } 103 | 104 | public func navigationController(_ navigationController: UINavigationController, 105 | willShow viewController: UIViewController, animated: Bool) { 106 | if pushingViewController { 107 | pushingViewController = false 108 | } else if !pushingViewController && topViewController != .none { 109 | // If a controller is not being pushed and the top view controller 110 | // is not nil then the navigation controller is poping the top view controller. 111 | // In which case the `onBack` message should be dispatched. 112 | // 113 | // The reason we need 'onControllerDidShow' is due to the fact that UIKit is calling 114 | // navigationController(didShow:,animated:) method twice. Apparently only when the pushed 115 | // controller is the first controller in the navigation stack. 116 | // 117 | // navigationController(willShow:,animated:) seems to be called only once so I decided to place 118 | // the logic to decide wether to dispatch the onBack message here but the message MUST be dispatched 119 | // in navigationController(didShow:,animated:) because that is when UIKit guarantees that transition 120 | // animation was completed. If you do things while being on a transition weird things happen or the 121 | // app can crash. 122 | // 123 | // To sumarize DO NOT dispatch a message inside this delegate's method. Do not trust UIKit. 124 | isPopingTopController = true 125 | if let onPop = self.onPop { 126 | onControllerDidShow = onPop 127 | } else if let message = currentNavigationBarOnBack { 128 | onControllerDidShow = { self.mailbox.dispatch(message: message) } 129 | } else { 130 | onControllerDidShow = .none 131 | } 132 | } 133 | } 134 | 135 | public func navigationController(_ navigationController: UINavigationController, 136 | didShow viewController: UIViewController, animated: Bool) { 137 | onControllerDidShow?() 138 | onControllerDidShow = .none 139 | onPop = .none 140 | isPopingTopController = false 141 | } 142 | 143 | } 144 | 145 | extension PortalNavigationController: ContainerController { 146 | 147 | public func registerDisposer(for identifier: String, disposer: @escaping () -> Void) { 148 | disposers[identifier] = disposer 149 | } 150 | 151 | } 152 | 153 | fileprivate extension PortalNavigationController { 154 | 155 | fileprivate func render(buttonItem: NavigationBarButton) -> UIBarButtonItem { 156 | switch buttonItem { 157 | 158 | case .textButton(let title, let message): 159 | let button = UIBarButtonItem(title: title) 160 | button.onTap(dispatch: message, to: mailbox) 161 | return button 162 | 163 | case .imageButton(let icon, let message): 164 | let button = UIBarButtonItem(icon: icon) 165 | button.onTap(dispatch: message, to: mailbox) 166 | return button 167 | 168 | } 169 | } 170 | 171 | } 172 | 173 | fileprivate var messageDispatcherAssociationKey = 0 174 | 175 | fileprivate extension UIBarButtonItem { 176 | 177 | fileprivate convenience init(title: String) { 178 | self.init(title: title, style: .plain, target: nil, action: nil) 179 | } 180 | 181 | fileprivate convenience init(icon: Image) { 182 | self.init(image: icon.asUIImage, style: .plain, target: nil, action: nil) 183 | } 184 | 185 | fileprivate func onTap(dispatch message: MessageType, to mailbox: Mailbox) { 186 | let dispatcher = MessageDispatcher(mailbox: mailbox, message: message) 187 | objc_setAssociatedObject(self, &messageDispatcherAssociationKey, dispatcher, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 188 | self.target = dispatcher 189 | self.action = dispatcher.selector 190 | } 191 | 192 | } 193 | -------------------------------------------------------------------------------- /Sources/UIKit/PortalTableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PortalTableView.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public final class PortalTableView: UITableView, UITableViewDataSource, UITableViewDelegate 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | public typealias CustomComponentRendererFactory = () -> CustomComponentRendererType 15 | 16 | public let mailbox = Mailbox() 17 | public var isDebugModeEnabled: Bool = false 18 | 19 | fileprivate let rendererFactory: CustomComponentRendererFactory 20 | fileprivate let layoutEngine: LayoutEngine 21 | fileprivate let items: [TableItemProperties] 22 | 23 | // Used to cache cell actual height after rendering table 24 | // item component. Caching cell height is usefull when 25 | // cells have dynamic height. 26 | fileprivate var cellHeights: [CGFloat?] 27 | 28 | public init(items: [TableItemProperties], layoutEngine: LayoutEngine, rendererFactory: @escaping CustomComponentRendererFactory) { 29 | self.rendererFactory = rendererFactory 30 | self.items = items 31 | self.layoutEngine = layoutEngine 32 | self.cellHeights = Array(repeating: .none, count: items.count) 33 | 34 | super.init(frame: .zero, style: .plain) 35 | 36 | self.dataSource = self 37 | self.delegate = self 38 | } 39 | 40 | required public init?(coder aDecoder: NSCoder) { 41 | fatalError("init(coder:) has not been implemented") 42 | } 43 | 44 | public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 45 | return items.count 46 | } 47 | 48 | public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 49 | let item = items[indexPath.row] 50 | let cellRender = itemRender(at: indexPath) 51 | let cell = dequeueReusableCell(with: cellRender.typeIdentifier) 52 | cell.component = cellRender.component 53 | 54 | let componentHeight = cell.component?.layout.height 55 | if componentHeight?.value == .none && componentHeight?.maximum == .none { 56 | // TODO replace this with a logger 57 | print("WARNING: Table item component with identifier '\(cellRender.typeIdentifier)' does not specify layout height! You need to either set layout.height.value or layout.height.maximum") 58 | } 59 | 60 | // For some reason the first page loads its cells with smaller bounds. 61 | // This forces the cell to have the width of its parent view. 62 | if let width = self.superview?.bounds.width { 63 | let baseHeight = itemBaseHeight(at: indexPath) 64 | cell.bounds.size.width = width 65 | cell.bounds.size.height = baseHeight 66 | cell.contentView.bounds.size.width = width 67 | cell.contentView.bounds.size.height = baseHeight 68 | } 69 | 70 | cell.selectionStyle = item.onTap.map { _ in item.selectionStyle.asUITableViewCellSelectionStyle } ?? .none 71 | cell.isDebugModeEnabled = isDebugModeEnabled 72 | cell.render() 73 | 74 | // After rendering the cell, the parent view returned by rendering the 75 | // item component has the actual height calculated after applying layout. 76 | // This height needs to be cached in order to be returned in the 77 | // UITableViewCellDelegate's method tableView(_,heightForRowAt:) 78 | let actualCellHeight = cell.contentView.subviews[0].bounds.height 79 | cellHeights[indexPath.row] = actualCellHeight 80 | cell.bounds.size.height = actualCellHeight 81 | cell.contentView.bounds.size.height = actualCellHeight 82 | 83 | return cell 84 | } 85 | 86 | public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 87 | return itemBaseHeight(at: indexPath) 88 | } 89 | 90 | public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 91 | let item = items[indexPath.row] 92 | item.onTap |> { mailbox.dispatch(message: $0) } 93 | } 94 | 95 | } 96 | 97 | fileprivate extension PortalTableView { 98 | 99 | fileprivate func dequeueReusableCell(with identifier: String) -> PortalTableViewCell { 100 | if let cell = dequeueReusableCell(withIdentifier: identifier) as? PortalTableViewCell { 101 | return cell 102 | } else { 103 | let cell = PortalTableViewCell( 104 | reuseIdentifier: identifier, 105 | layoutEngine: layoutEngine, 106 | rendererFactory: rendererFactory 107 | ) 108 | cell.mailbox.forward(to: mailbox) 109 | return cell 110 | } 111 | } 112 | 113 | fileprivate func itemRender(at indexPath: IndexPath) -> TableItemRender { 114 | // TODO cache the result of calling renderer. Once the diff algorithm is implemented find a way to only 115 | // replace items that have changed. 116 | // IGListKit uses some library or algorithm to diff array. Maybe that can be used to make the array diff 117 | // more efficient. 118 | // 119 | // https://github.com/Instagram/IGListKit 120 | // 121 | // Check the video of the talk that presents IGListKit to find the array diff algorithm. 122 | // Also there is Dwifft which seems to be based in the same algorithm: 123 | // 124 | // https://github.com/jflinter/Dwifft 125 | // 126 | let item = items[indexPath.row] 127 | return item.renderer(item.height) 128 | } 129 | 130 | fileprivate func itemMaxHeight(at indexPath: IndexPath) -> CGFloat { 131 | return CGFloat(items[indexPath.row].height) 132 | } 133 | 134 | 135 | /// Returns the cached actual height for the item at the given `indexPath`. 136 | /// Actual heights are cached using the `cellHeights` instance variable and 137 | /// are calculated after rending the item component inside the table view cell. 138 | /// This is usefull when cells have dynamic height. 139 | /// 140 | /// - Parameter indexPath: The item's index path. 141 | /// - Returns: The cached actual item height. 142 | fileprivate func itemActualHeight(at indexPath: IndexPath) -> CGFloat? { 143 | return cellHeights[indexPath.row] 144 | } 145 | 146 | 147 | /// Returns the item's cached actual height if available. Otherwise it 148 | /// returns the item's max height. 149 | /// 150 | /// - Parameter indexPath: The item's index path. 151 | /// - Returns: the item's cached actual height or its max height. 152 | fileprivate func itemBaseHeight(at indexPath: IndexPath) -> CGFloat { 153 | return itemActualHeight(at: indexPath) ?? itemMaxHeight(at: indexPath) 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /Sources/UIKit/PortalTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PortalTableViewCell.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public final class PortalTableViewCell: UITableViewCell 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | public typealias CustomComponentRendererFactory = () -> CustomComponentRendererType 15 | 16 | public let mailbox = Mailbox() 17 | public var component: Component? = .none 18 | public var isDebugModeEnabled: Bool { 19 | set { 20 | self.renderer?.isDebugModeEnabled = newValue 21 | } 22 | get { 23 | return self.renderer?.isDebugModeEnabled ?? false 24 | } 25 | } 26 | 27 | private var renderer: UIKitComponentRenderer? = .none 28 | 29 | public init(reuseIdentifier: String, layoutEngine: LayoutEngine, rendererFactory: @escaping CustomComponentRendererFactory) { 30 | super.init(style: .default, reuseIdentifier: reuseIdentifier) 31 | self.renderer = UIKitComponentRenderer( 32 | containerView: contentView, 33 | layoutEngine: layoutEngine, 34 | rendererFactory: rendererFactory 35 | ) 36 | } 37 | 38 | required public init?(coder aDecoder: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | public func render() { 43 | // TODO check if we need to do something about after layout hooks 44 | // TODO improve rendering performance by avoiding allocations. 45 | // Renderers should be able to reuse view objects instead of having 46 | // to allocate new ones if possible. 47 | if let component = self.component, let componentMailbox = renderer?.render(component: component) { 48 | componentMailbox.forward(to: mailbox) 49 | } 50 | } 51 | 52 | 53 | } 54 | -------------------------------------------------------------------------------- /Sources/UIKit/PortalViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PortalViewController.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public final class PortalViewController: UIViewController 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | public typealias RendererFactory = (ContainerController) -> UIKitComponentRenderer 15 | 16 | public var component: Component 17 | public let mailbox = Mailbox() 18 | public var orientation: SupportedOrientations = .all 19 | 20 | fileprivate var disposers: [String : () -> Void] = [:] 21 | 22 | private let createRenderer: RendererFactory 23 | 24 | public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { 25 | return orientation.uiInterfaceOrientation 26 | } 27 | 28 | public init(component: Component, factory createRenderer: @escaping RendererFactory) { 29 | self.component = component 30 | self.createRenderer = createRenderer 31 | super.init(nibName: nil, bundle: nil) 32 | } 33 | 34 | required public init?(coder aDecoder: NSCoder) { 35 | fatalError("init(coder:) has not been implemented") 36 | } 37 | 38 | deinit { 39 | disposers.values.forEach { $0() } 40 | } 41 | 42 | public override func loadView() { 43 | super.loadView() 44 | } 45 | 46 | public override func viewDidLoad() { 47 | // Not really sure why this is necessary but some users where having 48 | // issues when pushing controllers into Portal's navigation controller. 49 | // For some reason the pushed controller's view was being positioned 50 | // at {0,0} instead at {0, statusBarHeight + navBarHeight}. What was 51 | // even weirder was that this did not happend for all users. 52 | // This setting seems to fix the issue. 53 | edgesForExtendedLayout = [] 54 | render() 55 | } 56 | 57 | public func render() { 58 | // For some reason we need to calculate the view's frame 59 | // when updating a contained controller's view whos 60 | // parent is a navigation controller because if not the view 61 | // does not take into account the navigation and status bar in order 62 | // to sets its visible size. 63 | view.frame = calculateViewFrame() 64 | let renderer = createRenderer(self) 65 | let componentMailbox = renderer.render(component: component) 66 | componentMailbox.forward(to: mailbox) 67 | } 68 | 69 | } 70 | 71 | extension PortalViewController: ContainerController { 72 | 73 | public func registerDisposer(for identifier: String, disposer: @escaping () -> Void) { 74 | disposers[identifier] = disposer 75 | } 76 | 77 | } 78 | 79 | fileprivate extension PortalViewController { 80 | 81 | fileprivate var statusBarHeight: CGFloat { 82 | return UIApplication.shared.statusBarFrame.size.height 83 | } 84 | 85 | 86 | /// The bounds of the container view used to render the controller's component 87 | /// needs to be calcuated using this method because if the component is redenred 88 | /// on the viewDidLoad method for some reason UIKit reports the controller's view bounds 89 | /// to be equal to the screen's frame. Which does not take into account the status bar 90 | /// nor the navigation bar if the controllers is embeded inside a navigation controller. 91 | /// 92 | /// Also the supported orientation should be taken into account in order to define the bound's 93 | /// width and height. The supported orientation has higher priority to the device's orientation 94 | /// unless the supported orientation is all. 95 | /// 96 | /// The funny thing is that if you ask for the controller's view bounds inside viewWillAppear 97 | /// the bounds are properly set but the component needs to be rendered cannot be rendered in 98 | /// viewWillAppear because some views, like UITableView have unexpected behavior. 99 | /// 100 | /// - Returns: The view bounds that should be used to render the component's view 101 | fileprivate func calculateViewFrame() -> CGRect { 102 | var bounds = UIScreen.main.bounds 103 | if isViewInLandscapeOrientation() { 104 | // We need to check if the bounds has already been swapped. 105 | // After the device has been effectively been set in landscape mode 106 | // either by rotation the device of by forcing the supported orientation 107 | // UIKit returns the bounds size already swapped but the first time we 108 | // are forcing a landscape orientation we need to swap them manually. 109 | if bounds.size.width < bounds.height { 110 | bounds.size = bounds.size.swapped() 111 | } 112 | if let navBarBounds = navigationController?.navigationBar.bounds { 113 | bounds.size.width -= statusBarHeight + navBarBounds.size.height 114 | bounds.origin.x += statusBarHeight + navBarBounds.size.height 115 | } 116 | } else if let navBarBounds = navigationController?.navigationBar.bounds { 117 | // FIXME There is a bug that needs to be solved regarding the status 118 | // bug. When a modal landscape controller is being presented on top 119 | // of a portrait navigation controller, because in landscape mode the 120 | // status bar is not present, UIKit decides to hide the status bar before 121 | // performing the transition animation to present the modal controller. 122 | // 123 | // This has the effect of making the view bounds bigger because the 124 | // status bar is not visible anymore and because we do not perform 125 | // a re-layout, the view endups being moved to the new origin and 126 | // a black space appears at the bottom of the view. 127 | // 128 | // A possible solution would be to detect when a modal landscape 129 | // controller is being presented and then re-render the view which 130 | // would trigger a calculation of the layout that would take 131 | // into account the update view's bounds. 132 | bounds.size.height -= statusBarHeight + navBarBounds.size.height 133 | bounds.origin.y += statusBarHeight + navBarBounds.size.height 134 | } 135 | return bounds 136 | } 137 | 138 | fileprivate func isViewInLandscapeOrientation() -> Bool { 139 | switch orientation { 140 | case .landscape: 141 | return true 142 | case .portrait: 143 | return false 144 | case .all: 145 | return UIDevice.current.orientation.isLandscape 146 | } 147 | } 148 | 149 | } 150 | 151 | fileprivate extension CGSize { 152 | 153 | func swapped() -> CGSize { 154 | return CGSize(width: height, height: width) 155 | } 156 | 157 | } 158 | 159 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/ButtonRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ButtonRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public let defaultButtonFontSize = UInt(UIFont.buttonFontSize) 12 | 13 | internal struct ButtonRenderer: UIKitRenderer { 14 | 15 | let properties: ButtonProperties 16 | let style: StyleSheet 17 | let layout: Layout 18 | 19 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 20 | let button = UIButton() 21 | 22 | properties.text |> { button.setTitle($0, for: .normal) } 23 | properties.icon |> { button.setImage($0.asUIImage, for: .normal) } 24 | button.isSelected = properties.isActive 25 | 26 | button.apply(style: style.base) 27 | button.apply(style: style.component) 28 | layoutEngine.apply(layout: layout, to: button) 29 | 30 | button.unregisterDispatchers() 31 | button.removeTarget(.none, action: .none, for: .touchUpInside) 32 | let mailbox = button.bindMessageDispatcher { mailbox in 33 | properties.onTap |> { _ = button.dispatch(message: $0, for: .touchUpInside, with: mailbox) } 34 | } 35 | 36 | return Render(view: button, mailbox: mailbox) 37 | } 38 | 39 | } 40 | 41 | extension UIButton { 42 | 43 | fileprivate func dispatch(message: MessageType, for event: UIControlEvents, with mailbox: Mailbox = Mailbox()) -> Mailbox { 44 | let dispatcher = MessageDispatcher(mailbox: mailbox, message: message) 45 | self.register(dispatcher: dispatcher) 46 | self.addTarget(dispatcher, action: dispatcher.selector, for: event) 47 | return dispatcher.mailbox 48 | } 49 | 50 | } 51 | 52 | extension UIButton { 53 | 54 | fileprivate func apply(style: ButtonStyleSheet) { 55 | self.setTitleColor(style.textColor.asUIColor, for: .normal) 56 | style.textFont.uiFont(withSize: style.textSize) |> { self.titleLabel?.font = $0 } 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/CarouselRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CarouselRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Cristian Ames on 4/17/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal struct CarouselRenderer: UIKitRenderer 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | typealias CustomComponentRendererFactory = () -> CustomComponentRendererType 15 | 16 | let properties: CarouselProperties 17 | let style: StyleSheet 18 | let layout: Layout 19 | let rendererFactory: CustomComponentRendererFactory 20 | 21 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 22 | let carouselView = PortalCarouselView( 23 | items: properties.items, 24 | layoutEngine: layoutEngine, 25 | layout: createFlowLayout(), 26 | rendererFactory: rendererFactory, 27 | onSelectionChange: properties.onSelectionChange 28 | ) 29 | 30 | carouselView.isDebugModeEnabled = isDebugModeEnabled 31 | carouselView.isSnapToCellEnabled = properties.isSnapToCellEnabled 32 | carouselView.showsHorizontalScrollIndicator = properties.showsScrollIndicator 33 | 34 | carouselView.apply(style: style.base) 35 | layoutEngine.apply(layout: layout, to: carouselView) 36 | 37 | return Render(view: carouselView, mailbox: carouselView.mailbox) 38 | } 39 | 40 | func createFlowLayout() -> UICollectionViewFlowLayout { 41 | let layout = UICollectionViewFlowLayout() 42 | layout.itemSize = CGSize(width: CGFloat(properties.itemsWidth), height: CGFloat(properties.itemsHeight)) 43 | layout.minimumInteritemSpacing = CGFloat(properties.minimumInteritemSpacing) 44 | layout.minimumLineSpacing = CGFloat(properties.minimumLineSpacing) 45 | layout.sectionInset = UIEdgeInsets( 46 | top: CGFloat(properties.sectionInset.top), 47 | left: CGFloat(properties.sectionInset.left), 48 | bottom: CGFloat(properties.sectionInset.bottom), 49 | right: CGFloat(properties.sectionInset.right) 50 | ) 51 | 52 | layout.scrollDirection = .horizontal 53 | 54 | return layout 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/CollectionRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PortalCollectionView.swift 3 | // PortalView 4 | // 5 | // Created by Argentino Ducret on 4/4/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal struct CollectionRenderer: UIKitRenderer 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | typealias CustomComponentRendererFactory = () -> CustomComponentRendererType 15 | 16 | let properties: CollectionProperties 17 | let style: StyleSheet 18 | let layout: Layout 19 | let rendererFactory: CustomComponentRendererFactory 20 | 21 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 22 | let collectionView = PortalCollectionView( 23 | items: properties.items, 24 | layoutEngine: layoutEngine, 25 | layout: createFlowLayout(), 26 | rendererFactory: rendererFactory 27 | ) 28 | 29 | collectionView.isDebugModeEnabled = isDebugModeEnabled 30 | collectionView.showsHorizontalScrollIndicator = properties.showsHorizontalScrollIndicator 31 | collectionView.showsVerticalScrollIndicator = properties.showsVerticalScrollIndicator 32 | 33 | collectionView.apply(style: style.base) 34 | layoutEngine.apply(layout: layout, to: collectionView) 35 | 36 | return Render(view: collectionView, mailbox: collectionView.mailbox) 37 | } 38 | 39 | func createFlowLayout() -> UICollectionViewFlowLayout { 40 | let layout = UICollectionViewFlowLayout() 41 | layout.itemSize = CGSize(width: CGFloat(properties.itemsWidth), height: CGFloat(properties.itemsHeight)) 42 | layout.minimumInteritemSpacing = CGFloat(properties.minimumInteritemSpacing) 43 | layout.minimumLineSpacing = CGFloat(properties.minimumLineSpacing) 44 | layout.sectionInset = UIEdgeInsets( 45 | top: CGFloat(properties.sectionInset.top), 46 | left: CGFloat(properties.sectionInset.left), 47 | bottom: CGFloat(properties.sectionInset.bottom), 48 | right: CGFloat(properties.sectionInset.right) 49 | ) 50 | 51 | switch properties.scrollDirection { 52 | case .horizontal: 53 | layout.scrollDirection = .horizontal 54 | default: 55 | layout.scrollDirection = .vertical 56 | } 57 | 58 | return layout 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/ComponentRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComponentRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 4/10/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal struct ComponentRenderer: UIKitRenderer 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | typealias CustomComponentRendererFactory = () -> CustomComponentRendererType 15 | 16 | let component: Component 17 | let rendererFactory: CustomComponentRendererFactory 18 | 19 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 20 | switch component { 21 | 22 | case .button(let properties, let style, let layout): 23 | return ButtonRenderer(properties: properties, style: style, layout: layout) 24 | .render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 25 | 26 | case .label(let properties, let style, let layout): 27 | return LabelRenderer(properties: properties, style: style, layout: layout) 28 | .render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 29 | 30 | case .textField(let properties, let style, let layout): 31 | return TextFieldRenderer(properties: properties, style: style, layout: layout) 32 | .render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 33 | 34 | case .mapView(let properties, let style, let layout): 35 | return MapViewRenderer(properties: properties, style: style, layout: layout) 36 | .render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 37 | 38 | case .imageView(let image, let style, let layout): 39 | return ImageViewRenderer(image: image, style: style, layout: layout) 40 | .render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 41 | 42 | case .container(let children, let style, let layout): 43 | return ContainerRenderer( 44 | children: children, 45 | style: style, 46 | layout: layout, 47 | rendererFactory: rendererFactory 48 | ).render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 49 | 50 | case .table(let properties, let style, let layout): 51 | return TableRenderer( 52 | properties: properties, 53 | style: style, 54 | layout: layout, 55 | rendererFactory: rendererFactory 56 | ).render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 57 | 58 | case .touchable(let gesture, let child): 59 | return TouchableRenderer(child: child, gesture: gesture, rendererFactory: rendererFactory) 60 | .render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 61 | 62 | case .segmented(let segments, let style, let layout): 63 | return SegmentedRenderer(segments: segments, style: style, layout: layout) 64 | .render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 65 | 66 | case .collection(let properties, let style, let layout): 67 | return CollectionRenderer( 68 | properties: properties, 69 | style: style, 70 | layout: layout, 71 | rendererFactory: rendererFactory 72 | ).render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 73 | 74 | case .carousel(let properties, let style, let layout): 75 | return CarouselRenderer( 76 | properties: properties, 77 | style: style, 78 | layout: layout, 79 | rendererFactory: rendererFactory 80 | ).render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 81 | 82 | case .progress(let progress, let style, let layout): 83 | return ProgressRenderer(progress: progress, style: style, layout: layout) 84 | .render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 85 | 86 | case .custom(let componentIdentifier, let layout): 87 | let customComponentContainerView = UIView() 88 | layoutEngine.apply(layout: layout, to: customComponentContainerView) 89 | let mailbox = Mailbox() 90 | return Render(view: customComponentContainerView, mailbox: mailbox) { 91 | self.rendererFactory().renderComponent( 92 | withIdentifier: componentIdentifier, 93 | inside: customComponentContainerView, 94 | dispatcher: mailbox.dispatch 95 | ) 96 | } 97 | 98 | case .spinner(let isActive, let style, let layout): 99 | return SpinnerRenderer( 100 | isActive: isActive, 101 | style: style, 102 | layout: layout 103 | ).render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 104 | 105 | } 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/ContainerRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContainerRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal struct ContainerRenderer: UIKitRenderer 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | typealias CustomComponentRendererFactory = () -> CustomComponentRendererType 15 | 16 | let children: [Component] 17 | let style: StyleSheet 18 | let layout: Layout 19 | let rendererFactory: CustomComponentRendererFactory 20 | 21 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 22 | let view = UIView() 23 | view.managedByPortal = true 24 | 25 | var afterLayoutTasks: [AfterLayoutTask] = [] 26 | let mailbox = Mailbox() 27 | for child in children { 28 | let renderer = ComponentRenderer(component: child, rendererFactory: rendererFactory) 29 | let renderResult = renderer.render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 30 | renderResult.view.managedByPortal = true 31 | view.addSubview(renderResult.view) 32 | renderResult.afterLayout |> { afterLayoutTasks.append($0) } 33 | renderResult.mailbox |> { $0.forward(to: mailbox) } 34 | } 35 | 36 | view.apply(style: self.style.base) 37 | layoutEngine.apply(layout: self.layout, to: view) 38 | 39 | return Render(view: view, mailbox: mailbox) { 40 | afterLayoutTasks.forEach { $0() } 41 | } 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/FontRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public let defaultFont: Font = { 12 | let font = UIFont.systemFont(ofSize: UIFont.buttonFontSize) 13 | return font 14 | }() 15 | 16 | public extension Font { 17 | 18 | public func register(using bundle: Bundle = Bundle.main) -> Bool { 19 | guard let fontURL = bundle.url(forResource: self.name, withExtension: "ttf") else { return false } 20 | var error: Unmanaged? 21 | return CTFontManagerRegisterFontsForURL(fontURL as CFURL, .process, &error) 22 | } 23 | 24 | } 25 | 26 | extension Font { 27 | 28 | internal func uiFont(withSize size: CGFloat) -> UIFont? { 29 | return UIFont(name: self.name, size: size) 30 | } 31 | 32 | internal func uiFont(withSize size: UInt) -> UIFont? { 33 | return uiFont(withSize: CGFloat(size)) 34 | } 35 | 36 | } 37 | 38 | extension UIFont: Font { 39 | 40 | public var name: String { 41 | return self.fontName 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/ImageViewRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageViewRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal struct ImageViewRenderer: UIKitRenderer { 12 | 13 | let image: Image 14 | let style: StyleSheet 15 | let layout: Layout 16 | 17 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 18 | let imageView = UIImageView(image: image.asUIImage) 19 | imageView.clipsToBounds = true 20 | 21 | imageView.apply(style: style.base) 22 | layoutEngine.apply(layout: layout, to: imageView) 23 | 24 | return Render(view: imageView) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/LabelRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LabelRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal struct LabelRenderer: UIKitRenderer { 12 | 13 | let properties: LabelProperties 14 | let style: StyleSheet 15 | let layout: Layout 16 | 17 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 18 | let label = UILabel() 19 | label.text = properties.text 20 | 21 | label.apply(style: style.base) 22 | label.apply(style: style.component) 23 | layoutEngine.apply(layout: layout, to: label) 24 | 25 | return Render(view: label) { 26 | if let textAfterLayout = self.properties.textAfterLayout, let size = label.maximumFontSizeForWidth() { 27 | label.text = textAfterLayout 28 | label.font = label.font.withSize(size) 29 | label.adjustsFontSizeToFitWidth = false 30 | label.minimumScaleFactor = 0.0 31 | } 32 | } 33 | } 34 | 35 | } 36 | 37 | extension UILabel { 38 | 39 | fileprivate func apply(style: LabelStyleSheet) { 40 | let size = CGFloat(style.textSize) 41 | style.textFont.uiFont(withSize: size) |> { self.font = $0 } 42 | style.textColor |> { self.textColor = $0.asUIColor } 43 | self.textAlignment = style.textAligment.asNSTextAligment 44 | self.adjustsFontSizeToFitWidth = style.adjustToFitWidth 45 | self.numberOfLines = Int(style.numberOfLines) 46 | self.minimumScaleFactor = CGFloat(style.minimumScaleFactor) 47 | } 48 | 49 | } 50 | 51 | extension UILabel { 52 | 53 | fileprivate func maximumFontSizeForWidth() -> CGFloat? { 54 | guard let text = self.text else { return .none } 55 | return text.maximumFontSize(forWidth: self.frame.width, font: self.font) 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/MapViewRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MapViewRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import MapKit 11 | 12 | internal struct MapViewRenderer: UIKitRenderer { 13 | 14 | let properties: MapProperties 15 | let style: StyleSheet 16 | let layout: Layout 17 | 18 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 19 | let mapView = PortalMapView(placemarks: properties.placemarks) 20 | 21 | mapView.isZoomEnabled = properties.isZoomEnabled 22 | if let center = properties.center { 23 | let span = MKCoordinateSpanMake(properties.zoomLevel, properties.zoomLevel) 24 | let region = MKCoordinateRegion( 25 | center: CLLocationCoordinate2D(latitude: center.latitude, longitude: center.longitude), 26 | span: span 27 | ) 28 | mapView.setRegion(region, animated: true) 29 | } 30 | mapView.isScrollEnabled = properties.isScrollEnabled 31 | 32 | mapView.apply(style: style.base) 33 | layoutEngine.apply(layout: layout, to: mapView) 34 | 35 | return Render(view: mapView) 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/NavigationBarTitleRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationBarTitleRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal struct NavigationBarTitleRenderer 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | typealias CustomComponentRendererFactory = () -> CustomComponentRendererType 15 | 16 | let navigationBarTitle: NavigationBarTitle 17 | let navigationItem: UINavigationItem 18 | let navigationBarSize: CGSize 19 | let rendererFactory: CustomComponentRendererFactory 20 | 21 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Mailbox? { 22 | switch navigationBarTitle { 23 | 24 | case .text(let title): 25 | navigationItem.title = title 26 | return .none 27 | 28 | case .image(let image): 29 | navigationItem.titleView = UIImageView(image: image.asUIImage) 30 | return .none 31 | 32 | case .component(let titleComponent): 33 | let titleView = UIView(frame: CGRect(origin: .zero, size: navigationBarSize)) 34 | navigationItem.titleView = titleView 35 | var renderer = UIKitComponentRenderer( 36 | containerView: titleView, 37 | layoutEngine: layoutEngine, 38 | rendererFactory: rendererFactory 39 | ) 40 | renderer.isDebugModeEnabled = isDebugModeEnabled 41 | return renderer.render(component: titleComponent) 42 | } 43 | 44 | } 45 | 46 | } 47 | 48 | extension UINavigationBar { 49 | 50 | internal func apply(style: StyleSheet) { 51 | self.barTintColor = style.base.backgroundColor.asUIColor 52 | self.tintColor = style.component.tintColor.asUIColor 53 | self.isTranslucent = style.component.isTranslucent 54 | var titleTextAttributes: [String : Any] = [ 55 | NSForegroundColorAttributeName : style.component.titleTextColor.asUIColor, 56 | ] 57 | let font = style.component.titleTextFont 58 | let fontSize = style.component.titleTextSize 59 | font.uiFont(withSize: fontSize) |> { titleTextAttributes[NSFontAttributeName] = $0 } 60 | self.titleTextAttributes = titleTextAttributes 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/ProgressRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Cristian Ames on 4/11/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public let defaultTrackColor = Color.gray 12 | public let defaultProgressColor = Color.blue 13 | 14 | internal struct ProgressRenderer: UIKitRenderer { 15 | 16 | let progress: ProgressCounter 17 | let style: StyleSheet 18 | let layout: Layout 19 | 20 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 21 | let progressBar = UIProgressView(progressViewStyle: .default) 22 | progressBar.progress = progress.progress 23 | 24 | progressBar.apply(style: style.base) 25 | progressBar.apply(style: style.component) 26 | layoutEngine.apply(layout: layout, to: progressBar) 27 | 28 | return Render(view: progressBar) 29 | } 30 | 31 | } 32 | 33 | extension UIProgressView { 34 | 35 | fileprivate func apply(style: ProgressStyleSheet) { 36 | switch style.progressStyle { 37 | 38 | case .color(let color): 39 | progressTintColor = color.asUIColor 40 | 41 | case .image(let image): 42 | progressImage = image.asUIImage 43 | } 44 | 45 | switch style.trackStyle { 46 | 47 | case .color(let color): 48 | trackTintColor = color.asUIColor 49 | 50 | case .image(let image): 51 | trackImage = image.asUIImage 52 | } 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/SegmentedRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SegmentedRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Cristian Ames on 4/4/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal struct SegmentedRenderer: UIKitRenderer { 12 | 13 | let segments: ZipList> 14 | let style: StyleSheet 15 | let layout: Layout 16 | 17 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 18 | let segmentedControl = UISegmentedControl(items: []) 19 | 20 | for (index, segment) in segments.enumerated() { 21 | switch segment.content { 22 | case .image(let image): 23 | segmentedControl.insertSegment(with: image.asUIImage, at: index, animated: false) 24 | case .title(let text): 25 | segmentedControl.insertSegment(withTitle: text, at: index, animated: false) 26 | } 27 | segmentedControl.setEnabled(segment.isEnabled, forSegmentAt: index) 28 | } 29 | segmentedControl.selectedSegmentIndex = Int(segments.centerIndex) 30 | 31 | segmentedControl.apply(style: style.base) 32 | segmentedControl.apply(style: style.component) 33 | layoutEngine.apply(layout: layout, to: segmentedControl) 34 | 35 | segmentedControl.unregisterDispatchers() 36 | segmentedControl.removeTarget(.none, action: .none, for: .valueChanged) 37 | let mailbox = segmentedControl.bindMessageDispatcher { mailbox in 38 | _ = segmentedControl.dispatch( 39 | messages: segments.map { $0.onTap }, 40 | for: .valueChanged, with: mailbox 41 | ) 42 | } 43 | 44 | return Render(view: segmentedControl, mailbox: mailbox) 45 | } 46 | 47 | } 48 | 49 | extension UISegmentedControl { 50 | 51 | fileprivate func dispatch(messages: [MessageType?], for event: UIControlEvents, with mailbox: Mailbox = Mailbox()) -> Mailbox { 52 | 53 | let dispatcher = MessageDispatcher(mailbox: mailbox) { sender in 54 | guard let segmentedControl = sender as? UISegmentedControl else { return .none } 55 | let index = segmentedControl.selectedSegmentIndex 56 | return index < messages.count ? messages[index] : .none 57 | } 58 | self.register(dispatcher: dispatcher) 59 | self.addTarget(dispatcher, action: dispatcher.selector, for: event) 60 | return dispatcher.mailbox 61 | } 62 | 63 | } 64 | 65 | 66 | extension UISegmentedControl { 67 | 68 | fileprivate func apply(style: SegmentedStyleSheet) { 69 | self.tintColor = style.borderColor.asUIColor 70 | var dictionary = [String: Any]() 71 | let font = UIFont(name: style.textFont.name , size: CGFloat(style.textSize)) ?? .none 72 | dictionary[NSForegroundColorAttributeName] = style.textColor.asUIColor 73 | font.apply { dictionary[NSFontAttributeName] = $0 } 74 | self.setTitleTextAttributes(dictionary, for: .normal) 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/SpinnerRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpinnerRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Cristian Ames on 4/21/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal struct SpinnerRenderer: UIKitRenderer { 12 | 13 | let isActive: Bool 14 | let style: StyleSheet 15 | let layout: Layout 16 | 17 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 18 | let spinner = UIActivityIndicatorView(activityIndicatorStyle: .gray) 19 | 20 | spinner.hidesWhenStopped = false 21 | if isActive { 22 | spinner.startAnimating() 23 | } 24 | 25 | spinner.apply(style: style.base) 26 | spinner.apply(style: style.component) 27 | layoutEngine.apply(layout: layout, to: spinner) 28 | 29 | return Render(view: spinner) 30 | } 31 | 32 | } 33 | 34 | extension UIActivityIndicatorView { 35 | 36 | fileprivate func apply(style: SpinnerStyleSheet) { 37 | self.color = style.color.asUIColor 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/TableRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TableRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal struct TableRenderer: UIKitRenderer 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | typealias CustomComponentRendererFactory = () -> CustomComponentRendererType 15 | 16 | let properties: TableProperties 17 | let style: StyleSheet 18 | let layout: Layout 19 | let rendererFactory: CustomComponentRendererFactory 20 | 21 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 22 | let table = PortalTableView( 23 | items: properties.items, 24 | layoutEngine: layoutEngine, 25 | rendererFactory: rendererFactory 26 | ) 27 | 28 | table.isDebugModeEnabled = isDebugModeEnabled 29 | table.showsVerticalScrollIndicator = properties.showsVerticalScrollIndicator 30 | table.showsHorizontalScrollIndicator = properties.showsHorizontalScrollIndicator 31 | 32 | 33 | table.apply(style: style.base) 34 | table.apply(style: style.component) 35 | layoutEngine.apply(layout: layout, to: table) 36 | 37 | return Render(view: table, mailbox: table.mailbox) 38 | } 39 | 40 | } 41 | 42 | extension UITableView { 43 | 44 | fileprivate func apply(style: TableStyleSheet) { 45 | self.separatorColor = style.separatorColor.asUIColor 46 | } 47 | 48 | } 49 | 50 | extension TableItemSelectionStyle { 51 | 52 | internal var asUITableViewCellSelectionStyle: UITableViewCellSelectionStyle { 53 | switch self { 54 | case .none: 55 | return .none 56 | case .`default`: 57 | return .`default` 58 | case .blue: 59 | return .blue 60 | case .gray: 61 | return .gray 62 | } 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/TextFieldRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextFieldRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Juan Franco Caracciolo on 4/10/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal struct TextFieldRenderer: UIKitRenderer { 12 | 13 | let properties: TextFieldProperties 14 | let style: StyleSheet 15 | let layout: Layout 16 | 17 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 18 | let textField = UITextField() 19 | textField.placeholder = properties.placeholder 20 | textField.text = properties.text 21 | 22 | textField.apply(style: style.base) 23 | textField.apply(style: style.component) 24 | layoutEngine.apply(layout: layout, to: textField) 25 | 26 | textField.unregisterDispatchers() 27 | textField.removeTarget(.none, action: .none, for: .editingDidBegin) 28 | textField.removeTarget(.none, action: .none, for: .editingChanged) 29 | textField.removeTarget(.none, action: .none, for: .editingDidEnd) 30 | 31 | let mailbox: Mailbox = textField.bindMessageDispatcher { mailbox in 32 | properties.onEvents.onEditingBegin |> { _ = textField.dispatch(message: $0, for: .editingDidBegin, with: mailbox) } 33 | properties.onEvents.onEditingChanged |> { _ = textField.dispatch(message: $0, for: .editingChanged, with: mailbox) } 34 | properties.onEvents.onEditingEnd |> { _ = textField.dispatch(message: $0, for: .editingDidEnd, with: mailbox) } 35 | } 36 | 37 | return Render(view: textField, mailbox: mailbox) 38 | } 39 | 40 | } 41 | 42 | extension UITextField { 43 | 44 | fileprivate func apply(style: TextFieldStyleSheet) { 45 | let size = CGFloat(style.textSize) 46 | style.textFont.uiFont(withSize: size) |> { self.font = $0 } 47 | style.textColor |> { self.textColor = $0.asUIColor } 48 | style.textAligment |> { self.textAlignment = $0.asNSTextAligment } 49 | } 50 | 51 | } 52 | 53 | extension UITextField { 54 | 55 | fileprivate func dispatch(message: MessageType, for event: UIControlEvents, with mailbox: Mailbox = Mailbox()) -> Mailbox { 56 | let dispatcher = MessageDispatcher(mailbox: mailbox, message: message) 57 | self.register(dispatcher: dispatcher) 58 | self.addTarget(dispatcher, action: dispatcher.selector, for: event) 59 | return dispatcher.mailbox 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /Sources/UIKit/Renderers/TouchableRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TouchableRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 4/3/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal struct TouchableRenderer: UIKitRenderer 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | typealias CustomComponentRendererFactory = () -> CustomComponentRendererType 15 | 16 | let child: Component 17 | let gesture: Gesture 18 | let rendererFactory: CustomComponentRendererFactory 19 | 20 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render { 21 | let renderer = ComponentRenderer(component: child, rendererFactory: rendererFactory) 22 | var result = renderer.render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 23 | 24 | result.view.isUserInteractionEnabled = true 25 | 26 | switch gesture { 27 | 28 | case .tap(let message): 29 | let dispatcher: MessageDispatcher 30 | if let mailbox = result.mailbox { 31 | dispatcher = MessageDispatcher(mailbox: mailbox, message: message) 32 | } else { 33 | dispatcher = MessageDispatcher(message: message) 34 | result = Render(view: result.view, mailbox: dispatcher.mailbox, executeAfterLayout: result.afterLayout) 35 | } 36 | result.view.register(dispatcher: dispatcher) 37 | let recognizer = UITapGestureRecognizer(target: dispatcher, action: dispatcher.selector) 38 | result.view.addGestureRecognizer(recognizer) 39 | 40 | } 41 | return result 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /Sources/UIKit/UIKitComponentManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIKitComponentManager.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public final class UIKitComponentManager: Renderer 12 | where CustomComponentRendererType.MessageType == MessageType { 13 | 14 | public typealias ComponentRenderer = UIKitComponentRenderer 15 | public typealias CustomComponentRendererFactory = (ContainerController) -> CustomComponentRendererType 16 | 17 | public var isDebugModeEnabled: Bool = false 18 | 19 | public let mailbox = Mailbox() 20 | 21 | public var visibleController: ComponentController? { 22 | return window.visibleController 23 | } 24 | 25 | fileprivate let layoutEngine: LayoutEngine 26 | fileprivate let rendererFactory: CustomComponentRendererFactory 27 | fileprivate var window: WindowManager 28 | 29 | public init(window: UIWindow, layoutEngine: LayoutEngine = YogaLayoutEngine(), rendererFactory: @escaping CustomComponentRendererFactory) { 30 | self.window = WindowManager(window: window) 31 | self.rendererFactory = rendererFactory 32 | self.layoutEngine = layoutEngine 33 | } 34 | 35 | public func present(component: Component, with root: RootComponent, modally: Bool, orientation: SupportedOrientations, completion: @escaping () -> Void) { 36 | if modally { 37 | if window.currentModal != nil { 38 | dismissCurrentModal { 39 | self.presentModally(component: component, root: root, orientation: orientation, completion: completion) 40 | } 41 | } else { 42 | presentModally(component: component, root: root, orientation: orientation, completion: completion) 43 | } 44 | return 45 | } 46 | 47 | switch (window.visibleController, root) { 48 | case (.some(.navigationController(let navigationController)), .stack(let navigationBar)): 49 | let containedController = controller(for: component, orientation: orientation) 50 | navigationController.push(controller: containedController, with: navigationBar, animated: true, completion: completion) 51 | 52 | default: 53 | let rootController = controller(for: component, root: root, orientation: orientation) 54 | window.rootController = rootController 55 | rootController.mailbox.forward(to: mailbox) 56 | } 57 | } 58 | 59 | public func render(component: Component) -> Mailbox { 60 | switch window.visibleController { 61 | 62 | case .some(.single(let controller)): 63 | controller.component = component 64 | controller.render() 65 | return controller.mailbox 66 | 67 | case .some(.navigationController(let navigationController)): 68 | guard !navigationController.isPopingTopController else { 69 | print("Rendering skipped because controller is being poped") 70 | return Mailbox() 71 | } 72 | guard let topController = navigationController.topController else { 73 | // TODO better handle this case 74 | return Mailbox() 75 | } 76 | topController.component = component 77 | topController.render() 78 | return topController.mailbox 79 | 80 | default: 81 | let rootController = controller(for: component, orientation: .all) 82 | window.rootController = .single(rootController) 83 | rootController.mailbox.forward(to: mailbox) 84 | return rootController.mailbox 85 | } 86 | } 87 | 88 | public func render(component: Component, with root: RootComponent, orientation: SupportedOrientations) { 89 | switch (window.visibleController, root) { 90 | 91 | case (.some(.single(let controller)), .simple): 92 | controller.component = component 93 | controller.render() 94 | 95 | case (.some(.navigationController(let navigationController)), .stack(let navigationBar)): 96 | guard !navigationController.isPopingTopController else { 97 | print("Rendering skipped because controller is being poped") 98 | return 99 | } 100 | guard let topController = navigationController.topController else { 101 | // TODO better handle this case 102 | return 103 | } 104 | topController.component = component 105 | topController.render() 106 | navigationController.render(navigationBar: navigationBar, inside: topController.navigationItem) 107 | 108 | default: 109 | let rootController = controller(for: component, root: root, orientation: orientation) 110 | window.rootController = rootController 111 | rootController.mailbox.forward(to: mailbox) 112 | } 113 | 114 | // TODO Handle case where window.visibleController.orientation != orientation 115 | } 116 | 117 | public func dismissCurrentModal(completion: @escaping () -> Void) { 118 | window.currentModal?.renderableController.dismiss(animated: true) { 119 | self.window.currentModal = .none 120 | completion() 121 | } 122 | } 123 | 124 | } 125 | 126 | fileprivate extension UIKitComponentManager { 127 | 128 | fileprivate func presentModally(component: Component, root: RootComponent, orientation: SupportedOrientations, completion: @escaping () -> Void) { 129 | guard let presenter = window.visibleController?.renderableController else { return } 130 | 131 | let rootController = controller(for: component, root: root, orientation: orientation) 132 | rootController.mailbox.forward(to: mailbox) 133 | presenter.present(rootController.renderableController, animated: true, completion: completion) 134 | window.currentModal = rootController 135 | } 136 | 137 | fileprivate func controller(for component: Component, root: RootComponent, orientation: SupportedOrientations) 138 | -> ComponentController { 139 | switch root { 140 | 141 | case .simple: 142 | return .single(controller(for: component, orientation: orientation)) 143 | 144 | case .stack(let navigationBar): 145 | let navigationController = PortalNavigationController( 146 | layoutEngine: layoutEngine, 147 | statusBarStyle: navigationBar.style.component.statusBarStyle.asUIStatusBarStyle, 148 | rendererFactory: rendererFactory 149 | ) 150 | navigationController.orientation = orientation 151 | navigationController.isDebugModeEnabled = isDebugModeEnabled 152 | let containedController = controller(for: component, orientation: orientation) 153 | navigationController.push(controller: containedController, with: navigationBar, animated: false) { } 154 | return .navigationController(navigationController) 155 | 156 | case .tab(_): 157 | fatalError("Root component 'tab' not supported") 158 | } 159 | } 160 | 161 | fileprivate func controller(for component: Component, orientation: SupportedOrientations) -> PortalViewController { 162 | 163 | let controller: PortalViewController = PortalViewController(component: component) { container in 164 | var renderer = ComponentRenderer( 165 | containerView: container.containerView, 166 | layoutEngine: self.layoutEngine, 167 | rendererFactory: { self.rendererFactory(container) } 168 | ) 169 | renderer.isDebugModeEnabled = self.isDebugModeEnabled 170 | return renderer 171 | } 172 | controller.orientation = orientation 173 | 174 | return controller 175 | } 176 | 177 | } 178 | 179 | public enum ComponentController 180 | where CustomComponentRendererType.MessageType == MessageType { 181 | 182 | case navigationController(PortalNavigationController) 183 | case single(PortalViewController) 184 | 185 | public var renderableController: UIViewController { 186 | switch self { 187 | case .navigationController(let navigationController): 188 | return navigationController 189 | case .single(let controller): 190 | return controller 191 | } 192 | } 193 | 194 | public var mailbox: Mailbox { 195 | switch self { 196 | case .navigationController(let navigationController): 197 | return navigationController.mailbox 198 | case .single(let controller): 199 | return controller.mailbox 200 | } 201 | } 202 | 203 | } 204 | 205 | fileprivate struct WindowManager 206 | where CustomComponentRendererType.MessageType == MessageType { 207 | 208 | fileprivate var rootController: ComponentController? { 209 | set { 210 | window.rootViewController = newValue?.renderableController 211 | _rootController = newValue 212 | } 213 | get { 214 | return _rootController 215 | } 216 | } 217 | 218 | fileprivate var visibleController: ComponentController? { 219 | return currentModal ?? rootController 220 | } 221 | 222 | fileprivate var currentModal: ComponentController? 223 | 224 | private let window: UIWindow 225 | private var _rootController: ComponentController? 226 | 227 | init(window: UIWindow) { 228 | self.window = window 229 | self._rootController = .none 230 | self.rootController = .none 231 | self.currentModal = .none 232 | } 233 | 234 | } 235 | -------------------------------------------------------------------------------- /Sources/UIKit/UIKitRenderable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIKit.swift 3 | // Portal 4 | // 5 | // Created by Guido Marucci Blas on 12/13/16. 6 | // Copyright © 2016 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | 12 | 13 | 14 | 15 | internal protocol UIImageConvertible { 16 | 17 | var asUIImage: UIImage { get } 18 | 19 | } 20 | 21 | public typealias Image = UIImageContainer 22 | 23 | public struct UIImageContainer: ImageType, UIImageConvertible { 24 | 25 | public static func loadImage(named imageName: String, from bundle: Bundle = .main) -> UIImageContainer? { 26 | return UIImage(named: imageName, in: bundle, compatibleWith: .none).map(UIImageContainer.init) 27 | } 28 | 29 | public var size: Size { 30 | return Size(width: UInt(image.size.width), height: UInt(image.size.height)) 31 | } 32 | 33 | public func applyMask(_ mask: UIImageContainer) -> UIImageContainer? { 34 | guard let maskRef = mask.asUIImage.cgImage, 35 | let provider = mask.asUIImage.cgImage?.dataProvider, 36 | let cgImage = image.cgImage else { return .none } 37 | 38 | let mask = CGImage( 39 | maskWidth: maskRef.width, 40 | height: maskRef.height, 41 | bitsPerComponent: maskRef.bitsPerComponent, 42 | bitsPerPixel: maskRef.bitsPerPixel, 43 | bytesPerRow: maskRef.bytesPerRow, 44 | provider: provider, 45 | decode: nil, 46 | shouldInterpolate: false 47 | ) 48 | let maskedImage = mask 49 | .flatMap(cgImage.masking) 50 | .map(UIImage.init) 51 | .map(UIImageContainer.init) 52 | 53 | return maskedImage 54 | } 55 | 56 | var asUIImage: UIImage { 57 | return image 58 | } 59 | 60 | private let image: UIImage 61 | 62 | public init(image: UIImage) { 63 | self.image = image 64 | } 65 | 66 | } 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | extension StatusBarStyle { 85 | 86 | internal var asUIStatusBarStyle: UIStatusBarStyle { 87 | switch self { 88 | case .`default`: 89 | return .`default` 90 | case .lightContent: 91 | return .`lightContent` 92 | } 93 | } 94 | 95 | } 96 | 97 | internal protocol UIColorConvertible { 98 | 99 | var asUIColor: UIColor { get } 100 | 101 | } 102 | 103 | extension Color: UIColorConvertible { 104 | 105 | var asUIColor: UIColor { 106 | return UIColor(red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: CGFloat(alpha)) 107 | } 108 | 109 | } 110 | 111 | extension TextAligment { 112 | 113 | var asNSTextAligment: NSTextAlignment { 114 | switch self { 115 | case .left: 116 | return .left 117 | case .center: 118 | return .center 119 | case .right: 120 | return .right 121 | case .justified: 122 | return .justified 123 | case .natural: 124 | return .natural 125 | } 126 | } 127 | 128 | } 129 | 130 | 131 | 132 | extension String { 133 | 134 | func maximumFontSize(forWidth width: CGFloat, font: UIFont) -> CGFloat { 135 | let text = self as NSString 136 | let minimumBoundingRect = text.size(attributes: [NSFontAttributeName : font]) 137 | return width * font.pointSize / minimumBoundingRect.width 138 | } 139 | 140 | } 141 | 142 | 143 | -------------------------------------------------------------------------------- /Sources/UIKit/UIKitRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIKitRenderer.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public protocol ContainerController { 12 | 13 | var containerView: UIView { get } 14 | 15 | func attachChildController(_ controller: UIViewController) 16 | 17 | func registerDisposer(for identifier: String, disposer: @escaping () -> Void) 18 | 19 | } 20 | 21 | extension ContainerController where Self: UIViewController { 22 | 23 | public var containerView: UIView { 24 | return self.view 25 | } 26 | 27 | public func attachChildController(_ controller: UIViewController) { 28 | controller.willMove(toParentViewController: self) 29 | self.addChildViewController(controller) 30 | controller.didMove(toParentViewController: self) 31 | } 32 | 33 | } 34 | 35 | public protocol UIKitCustomComponentRenderer { 36 | 37 | associatedtype MessageType 38 | 39 | init(container: ContainerController) 40 | 41 | func renderComponent(withIdentifier identifier: String, inside view: UIView, dispatcher: @escaping (MessageType) -> Void) 42 | 43 | } 44 | 45 | public struct VoidCustomComponentRenderer: UIKitCustomComponentRenderer { 46 | 47 | public init(container: ContainerController) { 48 | 49 | } 50 | 51 | public func renderComponent(withIdentifier identifier: String, inside view: UIView, dispatcher: @escaping (MessageType) -> Void) { 52 | 53 | } 54 | } 55 | 56 | public struct UIKitComponentRenderer: Renderer 57 | where CustomComponentRendererType.MessageType == MessageType { 58 | 59 | public typealias CustomComponentRendererFactory = () -> CustomComponentRendererType 60 | 61 | public var isDebugModeEnabled: Bool = false 62 | 63 | internal let layoutEngine: LayoutEngine 64 | internal let rendererFactory: CustomComponentRendererFactory 65 | 66 | private let containerView: UIView 67 | 68 | public init( 69 | containerView: UIView, 70 | layoutEngine: LayoutEngine = YogaLayoutEngine(), 71 | rendererFactory: @escaping CustomComponentRendererFactory) { 72 | self.containerView = containerView 73 | self.rendererFactory = rendererFactory 74 | self.layoutEngine = layoutEngine 75 | } 76 | 77 | public func render(component: Component) -> Mailbox { 78 | containerView.subviews.forEach { $0.removeFromSuperview() } 79 | let renderer = ComponentRenderer(component: component, rendererFactory: rendererFactory) 80 | let renderResult = renderer.render(with: layoutEngine, isDebugModeEnabled: isDebugModeEnabled) 81 | renderResult.view.managedByPortal = true 82 | layoutEngine.layout(view: renderResult.view, inside: containerView) 83 | renderResult.afterLayout?() 84 | 85 | if isDebugModeEnabled { 86 | renderResult.view.safeTraverse { $0.addDebugFrame() } 87 | } 88 | 89 | return renderResult.mailbox ?? Mailbox() 90 | } 91 | 92 | } 93 | 94 | internal typealias AfterLayoutTask = () -> () 95 | 96 | internal struct Render { 97 | 98 | let view: UIView 99 | let mailbox: Mailbox? 100 | let afterLayout: AfterLayoutTask? 101 | 102 | init(view: UIView, mailbox: Mailbox? = .none, executeAfterLayout afterLayout: AfterLayoutTask? = .none) { 103 | self.view = view 104 | self.afterLayout = afterLayout 105 | self.mailbox = mailbox 106 | } 107 | 108 | } 109 | 110 | internal protocol UIKitRenderer { 111 | 112 | associatedtype MessageType 113 | 114 | func render(with layoutEngine: LayoutEngine, isDebugModeEnabled: Bool) -> Render 115 | 116 | } 117 | 118 | extension UIView { 119 | 120 | internal func apply(style: BaseStyleSheet) { 121 | style.backgroundColor |> { self.backgroundColor = $0.asUIColor } 122 | style.cornerRadius |> { self.layer.cornerRadius = CGFloat($0) } 123 | style.borderColor |> { self.layer.borderColor = $0.asUIColor.cgColor } 124 | style.borderWidth |> { self.layer.borderWidth = CGFloat($0) } 125 | style.alpha |> { self.alpha = CGFloat($0) } 126 | 127 | } 128 | 129 | } 130 | 131 | extension SupportedOrientations { 132 | 133 | var uiInterfaceOrientation: UIInterfaceOrientationMask { 134 | switch self { 135 | case .all: 136 | return .all 137 | case .landscape: 138 | return .landscape 139 | case .portrait: 140 | return .portrait 141 | } 142 | } 143 | 144 | } 145 | -------------------------------------------------------------------------------- /Sources/UIKit/UIViewExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewExtensions.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 2/14/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | internal extension UIView { 12 | 13 | internal func safeTraverse(visitor: @escaping (UIView) -> ()) { 14 | guard self.managedByPortal else { return } 15 | 16 | visitor(self) 17 | self.subviews.forEach { $0.safeTraverse(visitor: visitor) } 18 | } 19 | 20 | internal var managedByPortal: Bool { 21 | set { 22 | objc_setAssociatedObject(self, &managedByPortalAssociationKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 23 | } 24 | get { 25 | return objc_getAssociatedObject(self, &managedByPortalAssociationKey) as? Bool ?? false 26 | } 27 | } 28 | 29 | internal func register(dispatcher: MessageDispatcher) { 30 | let dispatchers = objc_getAssociatedObject(self, &messageDispatcherAssociationKey) as? NSMutableArray ?? NSMutableArray() 31 | dispatchers.add(dispatcher) 32 | objc_setAssociatedObject(self, &messageDispatcherAssociationKey, dispatchers, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 33 | } 34 | 35 | internal func unregisterDispatchers() { 36 | objc_setAssociatedObject(self, &messageDispatcherAssociationKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 37 | } 38 | 39 | internal func bindMessageDispatcher(binder: (Mailbox) -> ()) -> Mailbox { 40 | unregisterDispatchers() 41 | let mailbox = Mailbox() 42 | binder(mailbox) 43 | return mailbox 44 | } 45 | 46 | internal func addDebugFrame() { 47 | topBorder(thickness: 1.0, color: .red) 48 | bottomBorder(thickness: 1.0, color: .red) 49 | leftBorder(thickness: 1.0, color: .red) 50 | rightBorder(thickness: 1.0, color: .red) 51 | 52 | } 53 | 54 | internal func rotate360Degrees(duration: CFTimeInterval = 1.0, completionDelegate: CAAnimationDelegate? = .none) { 55 | let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation") 56 | rotateAnimation.fromValue = 0.0 57 | rotateAnimation.toValue = CGFloat(.pi * 2.0) 58 | rotateAnimation.duration = duration 59 | rotateAnimation.repeatCount = Float.infinity 60 | 61 | if let delegate = completionDelegate { 62 | rotateAnimation.delegate = delegate 63 | } 64 | layer.add(rotateAnimation, forKey: AnimationKey.rotation360.rawValue) 65 | } 66 | 67 | } 68 | 69 | fileprivate var managedByPortalAssociationKey = 0 70 | fileprivate var messageDispatcherAssociationKey = 0 71 | 72 | fileprivate enum AnimationKey: String { 73 | 74 | case rotation360 = "me.guidomb.PortalView.AnimationKey.360DegreeRotation" 75 | 76 | } 77 | 78 | fileprivate extension UIView { 79 | 80 | fileprivate func topBorder(thickness: Float, color: UIColor) { 81 | let borderView = UIView(frame: CGRect(x: 0, y: 0, width: superview!.bounds.width - 1.0, height: CGFloat(thickness))) 82 | borderView.backgroundColor = color 83 | addSubview(borderView) 84 | } 85 | 86 | fileprivate func bottomBorder(thickness: Float, color: UIColor) { 87 | let borderView = UIView(frame: CGRect(x: 0, y: bounds.height, width: superview!.bounds.width - 1.0, 88 | height: CGFloat(thickness))) 89 | borderView.backgroundColor = color 90 | addSubview(borderView) 91 | } 92 | 93 | fileprivate func leftBorder(thickness: Float, color: UIColor) { 94 | let borderView = UIView(frame: CGRect(x: 0, y: 0, width: CGFloat(thickness), height: bounds.height)) 95 | borderView.backgroundColor = color 96 | addSubview(borderView) 97 | } 98 | 99 | fileprivate func rightBorder(thickness: Float, color: UIColor) { 100 | let borderView = UIView(frame: CGRect(x: superview!.bounds.width - 1.0, y: 0, width: CGFloat(thickness), 101 | height: bounds.height)) 102 | borderView.backgroundColor = color 103 | addSubview(borderView) 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /Sources/ZipList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZipList.swift 3 | // PortalView 4 | // 5 | // Created by Guido Marucci Blas on 4/6/17. 6 | // Copyright © 2017 Guido Marucci Blas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct ZipList: Collection, CustomDebugStringConvertible { 12 | 13 | 14 | public var startIndex: Int { 15 | return 0 16 | } 17 | 18 | public var endIndex: Int { 19 | return count 20 | } 21 | 22 | public var count: Int { 23 | return left.count + right.count + 1 24 | } 25 | 26 | public var centerIndex: Int { 27 | return left.count 28 | } 29 | 30 | public var debugDescription: String { 31 | return "ZipList(\n\tleft: \(left)\n\tcenter: \(center)\n\tright: \(right))" 32 | } 33 | 34 | fileprivate let left: [Element] 35 | fileprivate let center: Element 36 | fileprivate let right: [Element] 37 | 38 | public init(element: Element) { 39 | self.init(left: [], center: element, right: []) 40 | } 41 | 42 | public init(left: [Element], center: Element, right: [Element]) { 43 | self.left = left 44 | self.center = center 45 | self.right = right 46 | } 47 | 48 | public subscript(index: Int) -> Element { 49 | precondition(index >= 0 && index < count, "Index of out bounds") 50 | if index < left.count { 51 | return left[index] 52 | } else if index == left.count { 53 | return center 54 | } else { 55 | return right[index - left.count - 1] 56 | } 57 | } 58 | 59 | public func index(after i: Int) -> Int { 60 | return i + 1 61 | } 62 | 63 | public func shiftLeft(count: UInt) -> ZipList? { 64 | guard count <= UInt(right.count) else { return .none } 65 | if count == 0 { return self } 66 | let newLeft = left + [center] + Array(right.dropLast(right.count + 1 - Int(count))) 67 | let newRight = Array(right.dropFirst(Int(count))) 68 | return ZipList(left: newLeft, center: right[Int(count) - 1], right: newRight) 69 | } 70 | 71 | public func shiftRight(count: UInt) -> ZipList? { 72 | guard count <= UInt(left.count) else { return .none } 73 | if count == 0 { return self } 74 | let newLeft = Array(left.dropLast(Int(count))) 75 | let newRight = Array(left.dropFirst(left.count + 1 - Int(count))) + [center] + right 76 | return ZipList(left: newLeft, center: left[left.count - Int(count)], right: newRight) 77 | } 78 | 79 | } 80 | 81 | extension ZipList { 82 | 83 | public func map(_ transform: @escaping (Element) -> NewElement) -> ZipList { 84 | return ZipList(left: left.map(transform), center: transform(center), right: right.map(transform)) 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 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 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import PortalViewTests 3 | 4 | XCTMain([ 5 | testCase(PortalViewTests.allTests), 6 | ]) 7 | -------------------------------------------------------------------------------- /Tests/PortalViewTests/PortalViewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import PortalView 3 | 4 | class PortalViewTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct results. 8 | XCTAssertEqual(PortalView().text, "Hello, World!") 9 | } 10 | 11 | 12 | static var allTests : [(String, (PortalViewTests) -> () throws -> Void)] { 13 | return [ 14 | ("testExample", testExample), 15 | ] 16 | } 17 | } 18 | --------------------------------------------------------------------------------