├── .version ├── .swift-version ├── Demo ├── Assets.xcassets │ ├── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── State │ └── CounterState.swift ├── Actions │ ├── DecrementCounter.swift │ └── IncrementCounter.swift ├── AppDelegate.swift ├── Info.plist ├── UI │ └── CounterScreen.swift └── LaunchScreen.storyboard ├── .github ├── Assets │ ├── demo_counter.gif │ ├── katana_header.png │ ├── demo_codingLove.gif │ ├── demo_minesweeper.gif │ └── demo_pokeAnimation.gif └── PULL_REQUEST_TEMPLATE.md ├── Examples ├── CodingLove │ ├── CodingLoveDemo.gif │ ├── Podfile │ ├── CodingLove │ │ ├── State │ │ │ └── CodingLoveState.swift │ │ ├── Models │ │ │ └── Post.swift │ │ ├── Assets.xcassets │ │ │ └── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ ├── Info.plist │ │ ├── AppDelegate.swift │ │ ├── Providers │ │ │ └── PostsProvider.swift │ │ ├── Actions │ │ │ └── FetchMorePosts.swift │ │ └── UI │ │ │ ├── PostCell.swift │ │ │ └── FetchMoreCell.swift │ ├── Podfile.lock │ ├── README.md │ └── .swiftlint.yml ├── Minesweeper │ ├── Minesweeper │ │ ├── Assets.xcassets │ │ │ ├── Contents.json │ │ │ ├── mine.imageset │ │ │ │ ├── mine.png │ │ │ │ └── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ ├── Actions │ │ │ ├── StartGame.swift │ │ │ ├── DiscloseCell.swift │ │ │ └── MinesweeperSyncAction.swift │ │ ├── Utils │ │ │ └── Random.swift │ │ ├── AppDelegate.swift │ │ ├── Info.plist │ │ ├── Base.lproj │ │ │ └── LaunchScreen.storyboard │ │ ├── UI │ │ │ ├── MinesweeperGrid.swift │ │ │ ├── Minesweeper.swift │ │ │ └── MinesweeperCell.swift │ │ └── State │ │ │ └── MinesweeperState.swift │ ├── MinesweeperDemo.gif │ ├── Podfile │ ├── Podfile.lock │ ├── README.md │ └── .swiftlint.yml └── PokeAnimations │ ├── PokeAnimations │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── eevee.imageset │ │ │ ├── eevee.png │ │ │ └── Contents.json │ │ ├── gotcha.imageset │ │ │ ├── gotcha.png │ │ │ └── Contents.json │ │ ├── pokeball.imageset │ │ │ ├── pokeball.png │ │ │ └── Contents.json │ │ ├── charmander.imageset │ │ │ ├── charmander.png │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── UI │ │ ├── Styles │ │ │ ├── NSAttributedString+Styles.swift │ │ │ └── UIColor+Styles.swift │ │ └── Intro │ │ │ └── KatanaElements+Intro.swift │ ├── AppDelegate.swift │ ├── Info.plist │ └── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── PokeAnimationsExample.gif │ ├── ATTRIBUTIONS │ ├── Podfile │ ├── Podfile.lock │ ├── .swiftlint.yml │ └── README.md ├── Podfile.lock ├── documentation.sh ├── Podfile ├── .gitignore ├── KatanaTests ├── Extensions │ └── Collections.swift ├── Core │ ├── RenderContainersTest.swift │ ├── NodeDescriptionTest.swift │ ├── StateMockProviderTests.swift │ ├── NodeDescriptionShouldUpdateTests.swift │ └── NodeTest.swift ├── Info.plist ├── Plastic │ └── Models │ │ └── HierarchyManagers.swift ├── NodeDescriptions │ ├── Image.swift │ └── View.swift └── Animations │ └── ChildrenAnimationsTests.swift ├── KatanaUI ├── Extensions │ ├── Int+Katana.swift │ └── Collections+Katana.swift ├── Katana.h ├── Info.plist ├── Core │ ├── Node │ │ ├── InternalNode.swift │ │ ├── Node+AnyNode.swift │ │ ├── PlatformNativeView.swift │ │ └── AnyNode.swift │ ├── Animations │ │ ├── AnimationType.swift │ │ ├── AnimationContainer.swift │ │ ├── Animation.swift │ │ ├── AnimationPropsTransfomer.swift │ │ └── ChildrenAnimations.swift │ ├── NodeDescription │ │ ├── Commons.swift │ │ └── NodeDescriptionWithChildren.swift │ └── StoreConnection │ │ └── ConnectedNodeDescription.swift └── Plastic │ ├── PlasticReferenceSizeable.swift │ ├── ViewsContainer+Helpers.swift │ ├── CoordinateConvertible.swift │ ├── Anchor.swift │ ├── Size.swift │ ├── PlasticNodeDescription.swift │ └── LayoutsCache.swift ├── KatanaElements ├── Buildable.swift ├── KatanaElements.h ├── TouchHandlerEvent.swift ├── Highlightable.swift ├── Info.plist ├── Table │ ├── TableDelegate.swift │ ├── TableCell.swift │ ├── Table.swift │ ├── NativeTableWrapperCell.swift │ └── NativeTable.swift ├── TouchHandler │ ├── TouchHandler.swift │ └── NativeTouchHandler.swift ├── NativeButton.swift ├── Image.swift ├── View.swift └── Label.swift ├── .sling.yml ├── .travis.yml ├── LICENSE ├── KatanaUI.podspec ├── .swiftlint.yml ├── KatanaElements.podspec ├── Cakefile ├── KatanaUI.xcodeproj └── xcshareddata │ └── xcschemes │ └── KatanaElements.xcscheme └── CONTRIBUTING.md /.version: -------------------------------------------------------------------------------- 1 | 1.0.0 -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 4.0 2 | -------------------------------------------------------------------------------- /Demo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /.github/Assets/demo_counter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/.github/Assets/demo_counter.gif -------------------------------------------------------------------------------- /.github/Assets/katana_header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/.github/Assets/katana_header.png -------------------------------------------------------------------------------- /.github/Assets/demo_codingLove.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/.github/Assets/demo_codingLove.gif -------------------------------------------------------------------------------- /.github/Assets/demo_minesweeper.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/.github/Assets/demo_minesweeper.gif -------------------------------------------------------------------------------- /.github/Assets/demo_pokeAnimation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/.github/Assets/demo_pokeAnimation.gif -------------------------------------------------------------------------------- /Examples/CodingLove/CodingLoveDemo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/Examples/CodingLove/CodingLoveDemo.gif -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Examples/Minesweeper/MinesweeperDemo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/Examples/Minesweeper/MinesweeperDemo.gif -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimationsExample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/Examples/PokeAnimations/PokeAnimationsExample.gif -------------------------------------------------------------------------------- /Examples/PokeAnimations/ATTRIBUTIONS: -------------------------------------------------------------------------------- 1 | Pokémon icons are designed by Roundicons Freebies from Flaticon. 2 | 3 | Pokémon and related content are copyrighted by The Pokémon Company International ("Pokémon"). -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/Assets.xcassets/mine.imageset/mine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/Examples/Minesweeper/Minesweeper/Assets.xcassets/mine.imageset/mine.png -------------------------------------------------------------------------------- /Examples/CodingLove/Podfile: -------------------------------------------------------------------------------- 1 | use_frameworks! 2 | platform :ios, '9.0' 3 | 4 | target 'CodingLove' do 5 | pod 'Katana', '~> 1.0' 6 | pod 'KatanaUI', :path => '../../' 7 | pod 'KatanaElements', :path => '../../' 8 | end 9 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Podfile: -------------------------------------------------------------------------------- 1 | use_frameworks! 2 | platform :ios, '9.0' 3 | 4 | target 'Minesweeper' do 5 | pod 'Katana', '~> 1.0' 6 | pod 'KatanaUI', :path => '../../' 7 | pod 'KatanaElements', :path => '../../' 8 | end 9 | -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/Assets.xcassets/eevee.imageset/eevee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/Examples/PokeAnimations/PokeAnimations/Assets.xcassets/eevee.imageset/eevee.png -------------------------------------------------------------------------------- /Examples/PokeAnimations/Podfile: -------------------------------------------------------------------------------- 1 | use_frameworks! 2 | platform :ios, '9.0' 3 | 4 | target 'PokeAnimations' do 5 | pod 'Katana', '~> 1.0' 6 | pod 'KatanaUI', :path => '../../' 7 | pod 'KatanaElements', :path => '../../' 8 | end 9 | -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/Assets.xcassets/gotcha.imageset/gotcha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/Examples/PokeAnimations/PokeAnimations/Assets.xcassets/gotcha.imageset/gotcha.png -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/Assets.xcassets/pokeball.imageset/pokeball.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/Examples/PokeAnimations/PokeAnimations/Assets.xcassets/pokeball.imageset/pokeball.png -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/Assets.xcassets/charmander.imageset/charmander.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BendingSpoonsArchive/katana-ui-swift/HEAD/Examples/PokeAnimations/PokeAnimations/Assets.xcassets/charmander.imageset/charmander.png -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Katana (1.0.0) 3 | 4 | DEPENDENCIES: 5 | - Katana (~> 1.0) 6 | 7 | SPEC CHECKSUMS: 8 | Katana: f45caad244d2f375d41068d94a7758b576940fd2 9 | 10 | PODFILE CHECKSUM: 3cdde1ed65ae618eb462df4d24166113515267c8 11 | 12 | COCOAPODS: 1.4.0 13 | -------------------------------------------------------------------------------- /documentation.sh: -------------------------------------------------------------------------------- 1 | jazzy \ 2 | --author "Bending Spoons Team" \ 3 | --author_url http://bendingspoons.com \ 4 | --github_url https://github.com/BendingSpoons/katana-ui-swift \ 5 | --github-file-prefix https://github.com/BendingSpoons/katana-ui-swift/blob/master/ \ 6 | --module KatanaUI 7 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | platform :ios, '9.0' 2 | use_frameworks! 3 | 4 | target 'KatanaUI' do 5 | pod 'Katana', '~> 1.0' 6 | 7 | target 'KatanaUITests' do 8 | end 9 | end 10 | 11 | target 'KatanaElements' do 12 | pod 'Katana', '~> 1.0' 13 | end 14 | 15 | target 'Demo' do 16 | pod 'Katana', '~> 1.0' 17 | end -------------------------------------------------------------------------------- /Demo/State/CounterState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CounterState.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Katana 10 | 11 | struct CounterState: State { 12 | var counter: Int = 0 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build/ 2 | *.xcworkspace 3 | *.pbxuser 4 | !default.pbxuser 5 | *.mode1v3 6 | !default.mode1v3 7 | *.mode2v3 8 | !default.mode2v3 9 | *.perspectivev3 10 | !default.perspectivev3 11 | xcuserdata 12 | *.xccheckout 13 | *.moved-aside 14 | DerivedData 15 | *.xcuserstate 16 | .DS_Store 17 | .idea 18 | Pods/ 19 | Carthage/ 20 | /build 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Why** 2 | Reason why you are making this PR (fix a bug, add a new feature, ...) 3 | 4 | **Changes** 5 | List relevant API changes 6 | 7 | **Tasks** 8 | * [ ] Add relevant tests, if needed 9 | * [ ] Add documentation, if needed 10 | * [ ] Update README, if needed 11 | * [ ] Ensure that all the examples (as well as the demo) work properly -------------------------------------------------------------------------------- /Examples/CodingLove/CodingLove/State/CodingLoveState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodingLoveState.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Katana 10 | 11 | struct CodingLoveState: State { 12 | var posts = [Post]() 13 | var loading = false 14 | var allPostsFetched = false 15 | 16 | var page = 0 17 | } 18 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/Assets.xcassets/mine.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "mine.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/Assets.xcassets/eevee.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "eevee.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/Assets.xcassets/gotcha.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "gotcha.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /KatanaTests/Extensions/Collections.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import KatanaUI 3 | 4 | class Collections: XCTestCase { 5 | 6 | func testIsSorted() { 7 | 8 | XCTAssertTrue([1, 2, 3].isSorted) 9 | XCTAssertTrue([1, 3, 5, 10].isSorted) 10 | XCTAssertTrue([1, 1, 2, 3, 10].isSorted) 11 | XCTAssertTrue([1, 1, 1].isSorted) 12 | XCTAssertFalse([10, 1, 2, 3].isSorted) 13 | XCTAssertFalse([10, 10, 1, 2, 3].isSorted) 14 | 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/Assets.xcassets/pokeball.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "pokeball.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/Assets.xcassets/charmander.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "charmander.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Demo/Actions/DecrementCounter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DecrementCounter.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Katana 10 | 11 | struct DecrementCounter: Action { 12 | func updatedState(currentState: State) -> State { 13 | guard var state = currentState as? CounterState else { fatalError("wrong state type") } 14 | state.counter -= 1 15 | return state 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Demo/Actions/IncrementCounter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IncrementCounter.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Katana 10 | 11 | struct IncrementCounter: Action { 12 | func updatedState(currentState: State) -> State { 13 | guard var state = currentState as? CounterState else { fatalError("wrong state type") } 14 | state.counter += 1 15 | return state 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/Actions/StartGame.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StartGame.swift 3 | // Minesweeper 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Katana 10 | 11 | struct StartGame: MinesweeperSyncAction { 12 | var payload: MinesweeperState.Difficulty 13 | 14 | func updatedState( currentState: inout MinesweeperState) { 15 | currentState = MinesweeperState(difficulty: self.payload) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /KatanaUI/Extensions/Int+Katana.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UInt32+Node.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | // 9 | 10 | import Foundation 11 | 12 | /// Int utilities for `Node` 13 | extension Int { 14 | /// A random `Int` value 15 | static var random: Int { 16 | let maxRandom = MemoryLayout.size == MemoryLayout.size ? UInt32(Int32.max) : UInt32.max 17 | return Int(arc4random_uniform(maxRandom)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /KatanaElements/Buildable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | public protocol Buildable { 12 | init() 13 | static func build(_ closure: (inout Self) -> ()) -> Self 14 | } 15 | 16 | public extension Buildable { 17 | static func build(_ closure: (inout Self) -> ()) -> Self { 18 | var sSelf = self.init() 19 | closure(&sSelf) 20 | return sSelf 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/Actions/DiscloseCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiscloseCell.swift 3 | // Minesweeper 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Katana 10 | 11 | struct DiscloseCell: MinesweeperSyncAction { 12 | var payload: (col: Int, row: Int) 13 | 14 | func updatedState(currentState: inout MinesweeperState) { 15 | let col = self.payload.col 16 | let row = self.payload.row 17 | currentState.disclose(col: col, row: row) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /KatanaUI/Katana.h: -------------------------------------------------------------------------------- 1 | // 2 | // Katana.h 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | @import UIKit; 10 | 11 | //! Project version number for Katana. 12 | FOUNDATION_EXPORT double KatanaVersionNumber; 13 | 14 | //! Project version string for Katana. 15 | FOUNDATION_EXPORT const unsigned char KatanaVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /.sling.yml: -------------------------------------------------------------------------------- 1 | project: KatanaUI 2 | 3 | documentation: 4 | author: Bending Spoons Team 5 | author_url: http://bendingspoons.com 6 | github_url: https://github.com/BendingSpoons/katana-ui-swift 7 | 8 | cocoapods: 9 | public_repo: 10 | email: opensource@bendingspoons.com 11 | author: Bending Spoons 12 | skip_linting: true # Cannot lint because it depends on Katana, released always together 13 | 14 | carthage: 15 | - KatanaUI 16 | - KatanaElements 17 | 18 | tests: 19 | runs: 20 | - scheme: KatanaUI 21 | sdk: iphonesimulator11.2 22 | simulator: platform=iOS Simulator,OS=11.2,name=iPhone 7 -------------------------------------------------------------------------------- /KatanaElements/KatanaElements.h: -------------------------------------------------------------------------------- 1 | // 2 | // KatanaElements.h 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | #import 10 | 11 | //! Project version number for Katana Elements. 12 | FOUNDATION_EXPORT double KatanaElementsVersionNumber; 13 | 14 | //! Project version string for Katana Elements. 15 | FOUNDATION_EXPORT const unsigned char KatanaElementsVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /Examples/CodingLove/CodingLove/Models/Post.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Post.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import UIKit 10 | 11 | struct Post: Equatable { 12 | let title: String 13 | let imageData: Data 14 | 15 | init(title: String, imageData: Data) { 16 | self.title = title 17 | self.imageData = imageData 18 | } 19 | 20 | static func == (lhs: Post, rhs: Post) -> Bool { 21 | return lhs.title == rhs.title && 22 | lhs.imageData == rhs.imageData 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Examples/CodingLove/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Katana (1.0.0) 3 | - KatanaElements (0.0.1): 4 | - KatanaUI (~> 0.0.1) 5 | - KatanaUI (0.0.1): 6 | - Katana (~> 1.0) 7 | 8 | DEPENDENCIES: 9 | - Katana (~> 1.0) 10 | - KatanaElements (from `../../`) 11 | - KatanaUI (from `../../`) 12 | 13 | EXTERNAL SOURCES: 14 | KatanaElements: 15 | :path: ../../ 16 | KatanaUI: 17 | :path: ../../ 18 | 19 | SPEC CHECKSUMS: 20 | Katana: f45caad244d2f375d41068d94a7758b576940fd2 21 | KatanaElements: 8d2f9b203fca2d056dad12ba1b25751949c1f816 22 | KatanaUI: c76386f6516318747eb29c20305f37d44e35bbf6 23 | 24 | PODFILE CHECKSUM: 5721b153354cf741375e640ec398cdd9983949c2 25 | 26 | COCOAPODS: 1.4.0 27 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/Actions/MinesweeperSyncAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinesweeperSyncAction.swift 3 | // Minesweeper 4 | // 5 | // Created by Andrea De Angelis on 14/11/2016. 6 | // Copyright © 2016 Andrea De Angelis. All rights reserved. 7 | // 8 | 9 | import Katana 10 | 11 | protocol MinesweeperSyncAction: Action { 12 | func updatedState(currentState: inout MinesweeperState) 13 | } 14 | 15 | extension MinesweeperSyncAction { 16 | func updatedState(currentState: State) -> State { 17 | guard var currentState = currentState as? MinesweeperState else { fatalError("unexpected app state") } 18 | self.updatedState(currentState: ¤tState) 19 | return currentState 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Katana (1.0.0) 3 | - KatanaElements (0.0.1): 4 | - KatanaUI (~> 0.0.1) 5 | - KatanaUI (0.0.1): 6 | - Katana (~> 1.0) 7 | 8 | DEPENDENCIES: 9 | - Katana (~> 1.0) 10 | - KatanaElements (from `../../`) 11 | - KatanaUI (from `../../`) 12 | 13 | EXTERNAL SOURCES: 14 | KatanaElements: 15 | :path: ../../ 16 | KatanaUI: 17 | :path: ../../ 18 | 19 | SPEC CHECKSUMS: 20 | Katana: f45caad244d2f375d41068d94a7758b576940fd2 21 | KatanaElements: 8d2f9b203fca2d056dad12ba1b25751949c1f816 22 | KatanaUI: c76386f6516318747eb29c20305f37d44e35bbf6 23 | 24 | PODFILE CHECKSUM: 4bef56601c2c019bdd5589886258a742f8f55904 25 | 26 | COCOAPODS: 1.4.0 27 | -------------------------------------------------------------------------------- /Examples/PokeAnimations/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Katana (1.0.0) 3 | - KatanaElements (0.0.1): 4 | - KatanaUI (~> 0.0.1) 5 | - KatanaUI (0.0.1): 6 | - Katana (~> 1.0) 7 | 8 | DEPENDENCIES: 9 | - Katana (~> 1.0) 10 | - KatanaElements (from `../../`) 11 | - KatanaUI (from `../../`) 12 | 13 | EXTERNAL SOURCES: 14 | KatanaElements: 15 | :path: ../../ 16 | KatanaUI: 17 | :path: ../../ 18 | 19 | SPEC CHECKSUMS: 20 | Katana: f45caad244d2f375d41068d94a7758b576940fd2 21 | KatanaElements: 8d2f9b203fca2d056dad12ba1b25751949c1f816 22 | KatanaUI: c76386f6516318747eb29c20305f37d44e35bbf6 23 | 24 | PODFILE CHECKSUM: 3394ec547f98eecbb6c9c83e78f0f1772316ead7 25 | 26 | COCOAPODS: 1.4.0 27 | -------------------------------------------------------------------------------- /KatanaTests/Core/RenderContainersTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Katana 3 | 4 | class UIViewsTest: XCTestCase { 5 | 6 | func testAddAndRemoveAll() { 7 | 8 | let r = UIView() 9 | 10 | let child = r.addChild { UIView() } 11 | XCTAssert(r.children().count == 1) 12 | XCTAssert(child.children().isEmpty) 13 | 14 | r.removeAllChildren() 15 | XCTAssert(r.children().isEmpty) 16 | } 17 | 18 | func testRemove() { 19 | 20 | let r = UIView() 21 | _ = r.addChild { UIView() } 22 | _ = r.addChild { UIView() } 23 | _ = r.addChild { UIView() } 24 | r.removeChild(r.children()[0]) 25 | let children = r.children() 26 | XCTAssert(children.count == 2) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /KatanaUI/Extensions/Collections+Katana.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collections+Katana.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | extension Collection where Iterator.Element: Comparable { 12 | 13 | /// Returns true if the collection is sorted 14 | var isSorted: Bool { 15 | var iterator = makeIterator() 16 | if var previous = iterator.next() { 17 | while let element = iterator.next() { 18 | if previous > element { 19 | return false 20 | } 21 | previous = element 22 | } 23 | } 24 | return true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /KatanaTests/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 | -------------------------------------------------------------------------------- /KatanaElements/TouchHandlerEvent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TouchHandlerEvent.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | public struct TouchHandlerEvent: OptionSet, Hashable { 12 | public let rawValue: Int 13 | 14 | public init(rawValue: Int) { 15 | self.rawValue = rawValue 16 | } 17 | 18 | public static var touchDown = TouchHandlerEvent(rawValue: 1 << 0) 19 | public static var touchUpInside = TouchHandlerEvent(rawValue: 1 << 1) 20 | public static var touchUpOutside = TouchHandlerEvent(rawValue: 1 << 2) 21 | public static var touchCancel = TouchHandlerEvent(rawValue: 1 << 3) 22 | 23 | public var hashValue: Int { 24 | return self.rawValue 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /KatanaElements/Highlightable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Highlightable.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import KatanaUI 11 | import Katana 12 | 13 | public protocol Highlightable { 14 | var highlighted: Bool { get set } 15 | } 16 | 17 | public struct EmptyHighlightableState: NodeDescriptionState, Highlightable { 18 | public var highlighted: Bool 19 | 20 | public init() { 21 | self.highlighted = false 22 | } 23 | 24 | public init(highlighted: Bool) { 25 | self.highlighted = highlighted 26 | } 27 | 28 | public static func == (lhs: EmptyHighlightableState, rhs: EmptyHighlightableState) -> Bool { 29 | return lhs.highlighted == rhs.highlighted 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /KatanaTests/Plastic/Models/HierarchyManagers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HierarchyManagers.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import CoreGraphics 11 | @testable import KatanaUI 12 | import UIKit 13 | import Katana 14 | 15 | // dummy hierarchy manager that always like the view 16 | // is the top one.. it always returns the passed value 17 | class DummyHierarchyManager: CoordinateConvertible { 18 | func getXCoordinate(_ absoluteValue: CGFloat, inCoordinateSystemOfParentOfKey key: String) -> CGFloat { 19 | return absoluteValue 20 | } 21 | 22 | func getYCoordinate(_ absoluteValue: CGFloat, inCoordinateSystemOfParentOfKey key: String) -> CGFloat { 23 | return absoluteValue 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: swift 2 | osx_image: xcode9 3 | env: 4 | global: 5 | - PROJECT=KatanaUI.xcodeproj 6 | matrix: 7 | - SCHEME="KatanaUI" 8 | SDK=iphonesimulator11.0 9 | SIMULATOR="platform=iOS Simulator,OS=11.0,name=iPhone 8" 10 | 11 | before_install: 12 | - export IOS_SIMULATOR_UDID=`instruments -s devices | grep "iPhone 8 (11.0" | awk -F '[ ]' '{print $4}' | awk -F '[[]' '{print $2}' | sed 's/.$//'` 13 | - echo $IOS_SIMULATOR_UDID 14 | - open -a "simulator" --args -CurrentDeviceUDID $IOS_SIMULATOR_UDID 15 | 16 | script: 17 | - set -o pipefail 18 | - xcodebuild -version 19 | - xcodebuild -showsdks 20 | - instruments -s devices 21 | 22 | # Build Framework in Debug and Run Tests if specified 23 | - travis_retry xcodebuild -project $PROJECT -scheme "$SCHEME" -sdk $SDK test -destination "$SIMULATOR" | xcpretty -c -------------------------------------------------------------------------------- /KatanaUI/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 | -------------------------------------------------------------------------------- /KatanaUI/Core/Node/InternalNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InternalNode.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | /** 12 | Internal protocol that allow the drawing of the node in a container. 13 | 14 | We basically don't want to expose `draw` as a public method. We want to force developers 15 | to call render only on the renderer by invoking 16 | 17 | ``` 18 | renderer = Renderer(rootDescription: rootNodeDescription, store: store) 19 | renderer.render(in: view) 20 | ``` 21 | */ 22 | protocol InternalAnyNode: AnyNode { 23 | /** 24 | Renders the node in the given container 25 | - parameter container: the container to use to draw the node 26 | */ 27 | func render(in container: PlatformNativeView) 28 | } 29 | -------------------------------------------------------------------------------- /KatanaElements/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 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/Utils/Random.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Random.swift 3 | // Minesweeper 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | // MARK: Random Int 13 | extension Int { 14 | static func random(max: Int) -> Int { 15 | return Int(arc4random_uniform(UInt32(max))) 16 | } 17 | } 18 | 19 | // MARK: Random CGFloat 20 | extension CGFloat { 21 | static func random(max: Int) -> CGFloat { 22 | return CGFloat(arc4random_uniform(UInt32(max))) 23 | } 24 | } 25 | 26 | // MARK: Random UIColor 27 | extension UIColor { 28 | static var randomColor: UIColor { 29 | return UIColor(red: CGFloat.random(max: 256) / 255.0, 30 | green: CGFloat.random(max: 256) / 255.0, 31 | blue: CGFloat.random(max: 256) / 255.0, 32 | alpha: 1.0) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Examples/CodingLove/README.md: -------------------------------------------------------------------------------- 1 | #CodingLove 2 | We all lived some of those moments only developers can laugh about. [thecodinglove.com](thecodinglove.com) is a simple website with a collection of Gifs about those moments. In this demo we implemented a very simple newsfeed for it! 3 | 4 | Here's the final result. 5 | 6 | ![](CodingLoveDemo.gif) 7 | 8 | Just a note about the implementation: we do not fetch the list of posts and their data directly from the website above. Instead we retrieve that data from a file bundled in the application (`posts.json`) in order to avoid scraping the website unnecessarily. We just simulate the responses to be paginated as they would be in a real scenario. 9 | 10 | ###What is showcased here: 11 | - Most of the basics of Katana; 12 | - Async Actions: Actions that are executed without blocking the application; 13 | - Tables: yes, we've tables 😱; 14 | - Providers: components that can be used to provide some functionality during side effects. 15 | 16 | -------------------------------------------------------------------------------- /KatanaUI/Plastic/PlasticReferenceSizeable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlasticReferenceSizeable.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import CoreGraphics 10 | /** 11 | Protocol that `NodeDescription` implementations can adopt to provide a reference size. 12 | 13 | Reference Size is used to calculate the Plastic multiplier. For a given node, the multiplier 14 | is calculated in the following way: 15 | 16 | - find the closest ancestor in the nodes tree 17 | 18 | - calculate the height multiplier: `ancestor.size.height / ancestor.description.referenceSize.height` 19 | 20 | - calculate the width multiplier: `ancestor.size.width / ancestor.description.referenceSize.width` 21 | 22 | - the Plastic multiplier is the minimum of the two 23 | */ 24 | public protocol PlasticReferenceSizeable { 25 | 26 | /// the reference size of the `NodeDescription` 27 | static var referenceSize: CGSize { get } 28 | } 29 | -------------------------------------------------------------------------------- /Examples/CodingLove/CodingLove/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 | "info" : { 45 | "version" : 1, 46 | "author" : "xcode" 47 | } 48 | } -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/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 | "info" : { 45 | "version" : 1, 46 | "author" : "xcode" 47 | } 48 | } -------------------------------------------------------------------------------- /Examples/Minesweeper/README.md: -------------------------------------------------------------------------------- 1 | # Minesweeper 2 | 3 | This example shows a quick implementation of the famous [Minesweeper](https://en.wikipedia.org/wiki/Minesweeper_(video_game)) game using the Katana framework. 4 | As you know the objective of the game is to clear a rectangular board containing hidden mines without detonating any of them, with help from clues about the number of neighboring mines in each field. 5 | 6 | Please note that this an incomplete implementation, __potential improvements are__: 7 | 8 | - Long press to flag a cell you think contains a mine. 9 | - Disclose all the mines on gameover/victory 10 | - Show the number of remaining mines 11 | 12 | Feel free to open a [pull request](https://github.com/BendingSpoons/katana-swift/pulls) for improvements! We'd love that. 13 | 14 | 15 | 16 | ![](MinesweeperDemo.gif) 17 | 18 | 19 | 20 | ### What is showcased here: 21 | 22 | - Most of the basics of Katana 23 | - Complex UI updates 24 | - `MinesweeperSyncAction` that abstracts all the `SyncAction`s of the app 25 | - Complex State logic -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) Copyright (c) 2016 Bending Spoons (http://bendingspoons.com/) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Examples/CodingLove/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - CodingLove 3 | 4 | whitelist_rules: 5 | - closing_brace 6 | - closure_spacing 7 | - colon 8 | - comma 9 | - control_statement 10 | - empty_count 11 | - explicit_init 12 | - file_length 13 | - function_body_length 14 | - implicit_getter 15 | - leading_whitespace 16 | - legacy_cggeometry_functions 17 | - legacy_constant 18 | - legacy_constructor 19 | - legacy_nsgeometry_functions 20 | - line_length 21 | - mark 22 | - opening_brace 23 | - operator_whitespace 24 | - overridden_super_call 25 | - private_unit_test 26 | - redundant_nil_coalescing 27 | - return_arrow_whitespace 28 | - statement_position 29 | - switch_case_on_newline 30 | - syntactic_sugar 31 | - todo 32 | - trailing_comma 33 | - trailing_newline 34 | - trailing_semicolon 35 | - type_body_length 36 | - type_name 37 | - variable_name 38 | - vertical_whitespace 39 | 40 | line_length: 130 41 | 42 | file_length: 43 | warning: 600 44 | 45 | function_body_length: 46 | warning: 100 47 | error: 200 48 | 49 | variable_name: 50 | min_length: 1 -------------------------------------------------------------------------------- /Examples/Minesweeper/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Minesweeper 3 | 4 | whitelist_rules: 5 | - closing_brace 6 | - closure_spacing 7 | - colon 8 | - comma 9 | - control_statement 10 | - empty_count 11 | - explicit_init 12 | - file_length 13 | - function_body_length 14 | - implicit_getter 15 | - leading_whitespace 16 | - legacy_cggeometry_functions 17 | - legacy_constant 18 | - legacy_constructor 19 | - legacy_nsgeometry_functions 20 | - line_length 21 | - mark 22 | - opening_brace 23 | - operator_whitespace 24 | - overridden_super_call 25 | - private_unit_test 26 | - redundant_nil_coalescing 27 | - return_arrow_whitespace 28 | - statement_position 29 | - switch_case_on_newline 30 | - syntactic_sugar 31 | - todo 32 | - trailing_comma 33 | - trailing_newline 34 | - trailing_semicolon 35 | - type_body_length 36 | - type_name 37 | - variable_name 38 | - vertical_whitespace 39 | 40 | line_length: 130 41 | 42 | file_length: 43 | warning: 600 44 | 45 | function_body_length: 46 | warning: 100 47 | error: 200 48 | 49 | variable_name: 50 | min_length: 1 -------------------------------------------------------------------------------- /Examples/PokeAnimations/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - PokeAnimations 3 | 4 | whitelist_rules: 5 | - closing_brace 6 | - closure_spacing 7 | - colon 8 | - comma 9 | - control_statement 10 | - empty_count 11 | - explicit_init 12 | - file_length 13 | - function_body_length 14 | - implicit_getter 15 | - leading_whitespace 16 | - legacy_cggeometry_functions 17 | - legacy_constant 18 | - legacy_constructor 19 | - legacy_nsgeometry_functions 20 | - line_length 21 | - mark 22 | - opening_brace 23 | - operator_whitespace 24 | - overridden_super_call 25 | - private_unit_test 26 | - redundant_nil_coalescing 27 | - return_arrow_whitespace 28 | - statement_position 29 | - switch_case_on_newline 30 | - syntactic_sugar 31 | - todo 32 | - trailing_comma 33 | - trailing_newline 34 | - trailing_semicolon 35 | - type_body_length 36 | - type_name 37 | - variable_name 38 | - vertical_whitespace 39 | 40 | line_length: 130 41 | 42 | file_length: 43 | warning: 600 44 | 45 | function_body_length: 46 | warning: 100 47 | error: 200 48 | 49 | variable_name: 50 | min_length: 1 -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/UI/Styles/NSAttributedString+Styles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString+Stykes.swift 3 | // PokeAnimations 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension NSAttributedString { 13 | static func buttonTitleString(_ content: String, for state: UIControlState) -> NSAttributedString { 14 | 15 | let color = state.contains(.highlighted) 16 | ? UIColor(white: 0, alpha: 0.3) 17 | : UIColor.black 18 | 19 | return NSAttributedString(string: content, attributes: [ 20 | NSFontAttributeName: UIFont(name: "HelveticaNeue", size: 20.0)!, 21 | NSForegroundColorAttributeName: color 22 | ]) 23 | } 24 | 25 | static func paragraphString(_ content: String) -> NSAttributedString { 26 | return NSAttributedString(string: content, attributes: [ 27 | NSFontAttributeName: UIFont(name: "HelveticaNeue", size: 25.0)!, 28 | NSForegroundColorAttributeName: UIColor(white: 0, alpha: 0.5) 29 | ]) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /KatanaUI.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'KatanaUI' 3 | s.version = File.read(".version") 4 | s.summary = 'UI for Katana' 5 | 6 | 7 | s.description = <<-DESC 8 | 9 | Katana is a modern Swift framework for writing iOS apps, strongly inspired by [React](https://facebook.github.io/react/) and [Redux](http://redux.js.org/), that gives structure to all the aspects of your app. 10 | 11 | KatanaElements include UI elements to be used with the Katana framework and allow you to easily get started 12 | 13 | DESC 14 | 15 | 16 | s.homepage = 'https://github.com/BendingSpoons/katana-ui-swift.git' 17 | s.license = { :type => 'MIT', :file => 'LICENSE' } 18 | s.author = { 'Bending Spoons' => 'team@bendingspoons.com' } 19 | s.source = { :git => 'https://github.com/BendingSpoons/katana-ui-swift.git', :tag => s.version.to_s } 20 | s.social_media_url = 'https://twitter.com/katana_swift' 21 | 22 | s.ios.deployment_target = '8.3' 23 | 24 | s.source_files = 'KatanaUI/**/**/*.{swift,h}' 25 | s.dependency 'Katana', "~> 1.0" 26 | end 27 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - KatanaUI 3 | - KatanaElements 4 | - KatanaTests 5 | - Demo 6 | 7 | whitelist_rules: 8 | - closing_brace 9 | - closure_spacing 10 | - colon 11 | - comma 12 | - control_statement 13 | - empty_count 14 | - explicit_init 15 | - file_length 16 | - function_body_length 17 | - implicit_getter 18 | - leading_whitespace 19 | - legacy_cggeometry_functions 20 | - legacy_constant 21 | - legacy_constructor 22 | - legacy_nsgeometry_functions 23 | - line_length 24 | - mark 25 | - opening_brace 26 | - operator_whitespace 27 | - overridden_super_call 28 | - private_unit_test 29 | - redundant_nil_coalescing 30 | - return_arrow_whitespace 31 | - statement_position 32 | - switch_case_on_newline 33 | - syntactic_sugar 34 | - todo 35 | - trailing_comma 36 | - trailing_newline 37 | - trailing_semicolon 38 | - type_body_length 39 | - type_name 40 | - variable_name 41 | - vertical_whitespace 42 | 43 | line_length: 130 44 | 45 | file_length: 46 | warning: 600 47 | 48 | function_body_length: 49 | warning: 100 50 | error: 200 51 | 52 | variable_name: 53 | min_length: 1 -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // PokeAnimations 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import UIKit 10 | import Katana 11 | import KatanaUI 12 | 13 | @UIApplicationMain 14 | class AppDelegate: UIResponder, UIApplicationDelegate { 15 | 16 | var window: UIWindow? 17 | var renderer: Renderer? 18 | 19 | func application(_ application: UIApplication, 20 | didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 21 | 22 | UIApplication.shared.isStatusBarHidden = true 23 | self.window = UIWindow(frame: UIScreen.main.bounds) 24 | self.window?.rootViewController = UIViewController() 25 | self.window?.makeKeyAndVisible() 26 | 27 | let view = self.window!.rootViewController!.view! 28 | let intro = Intro(props: Intro.Props(frame: UIScreen.main.bounds)) 29 | self.renderer = Renderer(rootDescription: intro, store: nil) 30 | self.renderer?.render(in: view) 31 | 32 | return true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /KatanaElements.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'KatanaElements' 3 | s.version = File.read(".version") 4 | s.summary = 'UI Elements for Katana' 5 | 6 | 7 | s.description = <<-DESC 8 | 9 | Katana is a modern Swift framework for writing iOS apps, strongly inspired by [React](https://facebook.github.io/react/) and [Redux](http://redux.js.org/), that gives structure to all the aspects of your app. 10 | 11 | KatanaElements include UI elements to be used with the Katana framework and allow you to easily get started 12 | 13 | DESC 14 | 15 | 16 | s.homepage = 'https://github.com/BendingSpoons/katana-ui-swift.git' 17 | s.license = { :type => 'MIT', :file => 'LICENSE' } 18 | s.author = { 'Bending Spoons' => 'team@bendingspoons.com' } 19 | s.source = { :git => 'https://github.com/BendingSpoons/katana-ui-swift.git', :tag => s.version.to_s } 20 | s.social_media_url = 'https://twitter.com/katana_swift' 21 | 22 | 23 | 24 | s.ios.deployment_target = '8.3' 25 | 26 | s.source_files = ['KatanaElements/**/**/*.{swift,h}'] 27 | s.dependency 'KatanaUI', "~> #{s.version.to_s}" 28 | end 29 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Minesweeper 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import UIKit 10 | import KatanaUI 11 | import Katana 12 | 13 | @UIApplicationMain 14 | class AppDelegate: UIResponder, UIApplicationDelegate { 15 | 16 | var window: UIWindow? 17 | var renderer: Renderer? 18 | 19 | func application(_ application: UIApplication, 20 | didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 21 | 22 | UIApplication.shared.isStatusBarHidden = true 23 | self.window = UIWindow(frame: UIScreen.main.bounds) 24 | self.window?.rootViewController = UIViewController() 25 | self.window?.makeKeyAndVisible() 26 | 27 | let view = self.window!.rootViewController!.view! 28 | let store = Store() 29 | let minesweeper = Minesweeper(props: Minesweeper.Props.build({ 30 | $0.frame = UIScreen.main.bounds 31 | })) 32 | self.renderer = Renderer(rootDescription: minesweeper, store: store) 33 | self.renderer?.render(in: view) 34 | 35 | return true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/UI/Styles/UIColor+Styles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor+Styles.swift 3 | // PokeAnimations 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIColor { 13 | static var charmender: UIColor { 14 | return UIColor( 15 | red: CGFloat(255.0 / 255.0), 16 | green: CGFloat(168.0 / 255.0), 17 | blue: CGFloat(72.0 / 255.0), 18 | alpha: 1.0 19 | ) 20 | } 21 | 22 | static var eevee: UIColor { 23 | return UIColor( 24 | red: CGFloat(216.0 / 255.0), 25 | green: CGFloat(168.0 / 255.0), 26 | blue: CGFloat(120.0 / 255.0), 27 | alpha: 1.0 28 | ) 29 | } 30 | 31 | static var pokeball: UIColor { 32 | return UIColor( 33 | red: CGFloat(216.0 / 255.0), 34 | green: CGFloat(240.0 / 255.0), 35 | blue: CGFloat(240.0 / 255.0), 36 | alpha: 1.0 37 | ) 38 | } 39 | 40 | static var gotcha: UIColor { 41 | return UIColor( 42 | red: CGFloat(255.0 / 255.0), 43 | green: CGFloat(144.0 / 255.0), 44 | blue: CGFloat(24.0 / 255.0), 45 | alpha: 1.0 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Examples/CodingLove/CodingLove/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 | NSAppTransportSecurity 24 | 25 | NSAllowsArbitraryLoads 26 | 27 | 28 | UILaunchStoryboardName 29 | LaunchScreen 30 | UIRequiredDeviceCapabilities 31 | 32 | armv7 33 | 34 | UISupportedInterfaceOrientations 35 | 36 | UIInterfaceOrientationPortrait 37 | UIInterfaceOrientationLandscapeLeft 38 | UIInterfaceOrientationLandscapeRight 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/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 | UIStatusBarHidden 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UIViewControllerBasedStatusBarAppearance 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /Demo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Demo 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Katana 10 | import KatanaElements 11 | import KatanaUI 12 | import UIKit 13 | 14 | @UIApplicationMain 15 | class AppDelegate: UIResponder, UIApplicationDelegate { 16 | 17 | var window: UIWindow? 18 | var renderer: Renderer? 19 | 20 | func application(_ application: UIApplication, 21 | didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 22 | self.window = UIWindow(frame: UIScreen.main.bounds) 23 | self.window?.rootViewController = UIViewController() 24 | self.window?.rootViewController?.view.backgroundColor = UIColor.white 25 | self.window?.makeKeyAndVisible() 26 | 27 | counterDemo() 28 | return true 29 | } 30 | 31 | private func counterDemo() { 32 | let view = (self.window?.rootViewController?.view)! 33 | let rootBounds = UIScreen.main.bounds 34 | 35 | let store = Store() 36 | let counterScreen = CounterScreen(props: CounterScreen.Props.build({ (counterScreenProps) in 37 | counterScreenProps.frame = rootBounds 38 | })) 39 | 40 | renderer = Renderer(rootDescription: counterScreen, store: store) 41 | renderer!.render(in: view) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /KatanaElements/Table/TableDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TableDelegate.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import KatanaUI 11 | import Katana 12 | 13 | public protocol TableDelegate { 14 | func numberOfSections() -> Int 15 | func numberOfRows(forSection section: Int) -> Int 16 | func cellDescription(forRowAt indexPath: IndexPath) -> AnyNodeDescription 17 | func height(forRowAt indexPath: IndexPath) -> Value 18 | func isEqual(to anotherDelegate: TableDelegate) -> Bool 19 | } 20 | 21 | public extension TableDelegate { 22 | func numberOfSections() -> Int { 23 | return 1 24 | } 25 | 26 | func isEqual(to anotherDelegate: TableDelegate) -> Bool { 27 | return false 28 | } 29 | } 30 | 31 | public struct EmptyTableDelegate: TableDelegate { 32 | public func numberOfSections() -> Int { 33 | return 0 34 | } 35 | 36 | public func numberOfRows(forSection section: Int) -> Int { 37 | return 0 38 | } 39 | 40 | public func cellDescription(forRowAt indexPath: IndexPath) -> AnyNodeDescription { 41 | fatalError() 42 | } 43 | 44 | public func height(forRowAt indexPath: IndexPath) -> Value { 45 | fatalError() 46 | } 47 | 48 | public func isEqual(to anotherDelegate: TableDelegate) -> Bool { 49 | return anotherDelegate is EmptyTableDelegate 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Examples/CodingLove/CodingLove/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // KatanaCodingLove 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import UIKit 10 | import Katana 11 | import KatanaUI 12 | 13 | @UIApplicationMain 14 | class AppDelegate: UIResponder, UIApplicationDelegate { 15 | 16 | var window: UIWindow? 17 | var renderer: Renderer? 18 | 19 | func application(_ application: UIApplication, 20 | didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]? = nil) -> Bool { 21 | 22 | self.window = UIWindow(frame: UIScreen.main.bounds) 23 | self.window?.rootViewController = UIViewController() 24 | self.window?.rootViewController?.view.backgroundColor = UIColor.white 25 | self.window?.makeKeyAndVisible() 26 | 27 | let view = (self.window?.rootViewController?.view)! 28 | let rootBounds = UIScreen.main.bounds 29 | 30 | let store = Store(middleware: [], dependencies: PostsProvider.self) 31 | 32 | self.renderer = Renderer(rootDescription: CodingLove(props: CodingLove.Props.build({ 33 | $0.frame = rootBounds 34 | })), store: store) 35 | 36 | store.dispatch(FetchMorePosts(payload: ())) 37 | 38 | self.renderer!.render(in: view) 39 | 40 | return true 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Demo/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 | -------------------------------------------------------------------------------- /KatanaTests/Core/NodeDescriptionTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Katana 3 | 4 | class NodeDescriptionTest: XCTestCase { 5 | private func view(withBackground color: UIColor, key: String?) -> View { 6 | var props = ViewProps() 7 | props.backgroundColor = color 8 | props.key = key 9 | return View(props: props) 10 | } 11 | 12 | private func image(withBackground color: UIColor, key: String?) -> Image { 13 | var props = ImageProps() 14 | props.backgroundColor = color 15 | props.key = key 16 | return Image(props: props) 17 | } 18 | 19 | func testreplaceKey() { 20 | let view1 = view(withBackground: .blue, key: nil) 21 | let view2 = view(withBackground: .blue, key: nil) 22 | let img = image(withBackground: .blue, key: nil) 23 | 24 | XCTAssert(view1.replaceKey == view1.replaceKey) 25 | XCTAssert(view1.replaceKey == view2.replaceKey) 26 | XCTAssert(img.replaceKey == img.replaceKey) 27 | XCTAssert(img.replaceKey != view1.replaceKey) 28 | } 29 | 30 | func testReplaceKeyWihtKeyableProps() { 31 | let view1 = view(withBackground: .blue, key: "a") 32 | let view2 = view(withBackground: .blue, key: "a") 33 | let view3 = view(withBackground: .blue, key: "b") 34 | let image1 = image(withBackground: .blue, key: "a") 35 | let image2 = image(withBackground: .blue, key: "d") 36 | 37 | XCTAssert(view1.replaceKey == view1.replaceKey) 38 | XCTAssert(view1.replaceKey == view2.replaceKey) 39 | XCTAssert(view1.replaceKey != view3.replaceKey) 40 | XCTAssert(view1.replaceKey != image1.replaceKey) 41 | XCTAssert(view1.replaceKey != image2.replaceKey) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/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 | UIRequiresFullScreen 30 | 31 | UIStatusBarHidden 32 | 33 | UISupportedInterfaceOrientations 34 | 35 | UIInterfaceOrientationPortrait 36 | UIInterfaceOrientationLandscapeLeft 37 | UIInterfaceOrientationLandscapeRight 38 | 39 | UISupportedInterfaceOrientations~ipad 40 | 41 | UIInterfaceOrientationPortrait 42 | UIInterfaceOrientationPortraitUpsideDown 43 | UIInterfaceOrientationLandscapeLeft 44 | UIInterfaceOrientationLandscapeRight 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /KatanaUI/Core/Animations/AnimationType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimationType.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import CoreGraphics 11 | import UIKit 12 | 13 | /// Enum that represents the animations that can be used to animate an UI update 14 | public enum AnimationType { 15 | 16 | /// No animation 17 | case none 18 | 19 | /// Linear animation with a given duration 20 | case linear(duration: TimeInterval) 21 | 22 | /// Linear animation with given duration and options 23 | case linearWithOptions(duration: TimeInterval, 24 | options: UIViewAnimationOptions) 25 | 26 | /// Liear animation with given duration, options and delay 27 | case linearWithDelay(duration: TimeInterval, 28 | options: UIViewAnimationOptions, 29 | delay: TimeInterval) 30 | 31 | /// Spring animation with duration, damping and initialVelocity 32 | case spring(duration: TimeInterval, damping: CGFloat, initialVelocity: CGFloat) 33 | 34 | /// Spring animation with duration, damping, initialVelocity and options 35 | case springWithOptions(duration: TimeInterval, 36 | damping: CGFloat, 37 | initialVelocity: CGFloat, 38 | options: UIViewAnimationOptions) 39 | 40 | /// Spring animation with duration, damping, initialVelocity, options and delay 41 | case springWithDelay(duration: TimeInterval, 42 | damping: CGFloat, 43 | initialVelocity: CGFloat, 44 | options: UIViewAnimationOptions, 45 | delay: TimeInterval) 46 | 47 | } 48 | -------------------------------------------------------------------------------- /KatanaUI/Plastic/ViewsContainer+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlasticViewsContainer+Helpers.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | /// Extension of `ViewsContainer` with some helper methods 12 | public extension ViewsContainer { 13 | 14 | /** 15 | Returns the views contained in the ViewsContainer that passes the filter 16 | 17 | - parameter filter: a closure that returns whether the view associated with the key 18 | should be in the final result or not 19 | 20 | - returns: a dictionary with all the views that have passed the filter. They key of the dictionary 21 | is the key associated with the node description (and therefore to the view) 22 | */ 23 | public func filtered(with filter: (String) -> Bool) -> [String: PlasticView] { 24 | var newDict = [String: PlasticView]() 25 | 26 | for (key, view) in self.views { 27 | if filter(key) { 28 | newDict[key] = view 29 | } 30 | } 31 | 32 | return newDict 33 | } 34 | 35 | /** 36 | Returns an array of views that passes the filter, and ordered using the `sort` closure 37 | 38 | - parameter filter: the filter to apply to the views 39 | - parameter sort: a closure that that can be used to sort the view's keys 40 | 41 | - note: you can use standard Swift operators such as `>` or `<` as `sort` parameter 42 | */ 43 | public func orderedViews(filter: (String) -> Bool, sortedBy sort: (String, String) -> Bool) -> [PlasticView] { 44 | return self 45 | .filtered(with: filter) 46 | .map { $0.key } 47 | .sorted() 48 | .flatMap { self.views[$0] } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /KatanaElements/Table/TableCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CellNodeDescription.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import KatanaUI 11 | import Katana 12 | 13 | public protocol AnyTableCell: AnyNodeDescription { 14 | static func anyDidTap(dispatch: StoreDispatch, props: Any, indexPath: IndexPath) 15 | } 16 | 17 | public protocol TableCell: NodeDescription, AnyTableCell { 18 | associatedtype NativeView: NativeTableCell = NativeTableCell 19 | associatedtype StateType: Highlightable = EmptyHighlightableState 20 | 21 | static func didTap(dispatch: StoreDispatch, props: PropsType, indexPath: IndexPath) 22 | } 23 | 24 | public extension TableCell { 25 | public static func applyPropsToNativeView(props: PropsType, 26 | state: StateType, 27 | view: NativeView, 28 | update: @escaping (StateType)->(), 29 | node: AnyNode) { 30 | 31 | view.frame = props.frame 32 | 33 | view.update = { (highlighted: Bool) in 34 | var newState = state 35 | newState.highlighted = highlighted 36 | update(newState) 37 | } 38 | } 39 | 40 | public static func anyDidTap(dispatch: StoreDispatch, props: Any, indexPath: IndexPath) { 41 | if let p = props as? PropsType { 42 | self.didTap(dispatch: dispatch, props: p, indexPath: indexPath) 43 | } 44 | } 45 | } 46 | 47 | public class NativeTableCell: UIView { 48 | var update: ((Bool) -> ())? 49 | 50 | func setHighlighted(_ highlighted: Bool) { 51 | if let update = self.update { 52 | update(highlighted) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /KatanaUI/Core/NodeDescription/Commons.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Commons.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import CoreGraphics 10 | 11 | /// The default props for a `NodeDescription`. Besides `frame` and `key`, this struct doesn't have any other property 12 | public struct EmptyProps: NodeDescriptionProps { 13 | /// The alpha of the description 14 | public var alpha: CGFloat = 1.0 15 | 16 | /// The key of the description 17 | public var key: String? 18 | 19 | /// The frame of the description 20 | public var frame: CGRect = CGRect.zero 21 | 22 | /** 23 | Implementation of the `Equatable` protocol 24 | 25 | - parameter lhs: the first props 26 | - parameter rhs: the second props 27 | - returns: true if frame and key are the same, false otherwise 28 | */ 29 | public static func == (lhs: EmptyProps, rhs: EmptyProps) -> Bool { 30 | return 31 | lhs.frame == rhs.frame && 32 | lhs.key == rhs.key && 33 | lhs.alpha == rhs.alpha 34 | } 35 | 36 | /** 37 | Default initializer of the struct 38 | - returns: a valid instance of EmptyProps 39 | */ 40 | public init() {} 41 | } 42 | 43 | /// The default state for a `NodeDescription`. This struct is basically empty 44 | public struct EmptyState: NodeDescriptionState { 45 | /** 46 | Implementation of the `Equatable` protocol 47 | 48 | - parameter lhs: the first state 49 | - parameter rhs: the second state 50 | - returns: true 51 | */ 52 | public static func == (lhs: EmptyState, rhs: EmptyState) -> Bool { 53 | return true 54 | } 55 | 56 | /** 57 | Default initializer of the struct 58 | - returns: a valid instance of EmptyState 59 | */ 60 | public init() {} 61 | } 62 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/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 | -------------------------------------------------------------------------------- /KatanaUI/Core/Node/Node+AnyNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Node+AnyNode.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | extension Node: AnyNode { 12 | /** 13 | Implementation of the AnyNode protocol. 14 | 15 | - seeAlso: `AnyNode` 16 | */ 17 | public var anyDescription: AnyNodeDescription { 18 | return self.description 19 | } 20 | 21 | /** 22 | Implementation of the AnyNode protocol. 23 | 24 | - seeAlso: `AnyNode` 25 | */ 26 | public var anyState: Any { 27 | return self.state 28 | } 29 | 30 | /** 31 | Implementation of the AnyNode protocol. 32 | 33 | - seeAlso: `AnyNode` 34 | */ 35 | public func update(with description: AnyNodeDescription) { 36 | self.update(with: description, animation: .none, completion: nil) 37 | } 38 | 39 | /** 40 | Implementation of the AnyNode protocol. 41 | 42 | - seeAlso: `AnyNode` 43 | */ 44 | public func update(with description: AnyNodeDescription, animation: AnimationContainer, completion: NodeUpdateCompletion?) { 45 | guard var description = description as? Description else { 46 | fatalError("Impossible to use the provided description to update the node") 47 | } 48 | 49 | description.props = Node.updatedPropsWithConnect( 50 | description: description, 51 | props: description.props, 52 | store: self.renderer?.store 53 | ) 54 | 55 | self.update(for: self.state, description: description, animation: animation, completion: completion) 56 | } 57 | 58 | /** 59 | Implementation of the AnyNode protocol. 60 | 61 | - seeAlso: `AnyNode` 62 | */ 63 | public func forceReload() { 64 | self.update(for: self.state, description: self.description, animation: .none, force: true) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/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 | -------------------------------------------------------------------------------- /Demo/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 | } -------------------------------------------------------------------------------- /KatanaTests/NodeDescriptions/Image.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Image.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import UIKit 10 | import Foundation 11 | import KatanaUI 12 | import Katana 13 | 14 | public struct ImageProps: NodeDescriptionProps { 15 | public var frame = CGRect.zero 16 | public var key: String? 17 | public var alpha: CGFloat = 1.0 18 | 19 | public var backgroundColor = UIColor.white 20 | public var image: UIImage? = nil 21 | 22 | public init() {} 23 | 24 | public static func == (lhs: ImageProps, rhs: ImageProps) -> Bool { 25 | return 26 | lhs.frame == rhs.frame && 27 | lhs.key == rhs.key && 28 | lhs.alpha == rhs.alpha && 29 | lhs.backgroundColor == rhs.backgroundColor && 30 | lhs.image == rhs.image 31 | } 32 | } 33 | 34 | public struct Image: NodeDescription { 35 | public typealias NativeView = UIImageView 36 | 37 | public var props: ImageProps 38 | 39 | public static func applyPropsToNativeView(props: ImageProps, 40 | state: EmptyState, 41 | view: UIImageView, 42 | update: @escaping (EmptyState)->(), 43 | node: AnyNode) { 44 | 45 | view.frame = props.frame 46 | view.backgroundColor = props.backgroundColor 47 | view.image = props.image 48 | } 49 | 50 | public static func childrenDescriptions(props: ImageProps, 51 | state: EmptyState, 52 | update: @escaping (EmptyState)->(), 53 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 54 | return [] 55 | } 56 | 57 | public init(props: ImageProps) { 58 | self.props = props 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/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 | } -------------------------------------------------------------------------------- /KatanaUI/Core/NodeDescription/NodeDescriptionWithChildren.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodeDescriptionWithChildren.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | /// Type Erasure for `NodeDescriptionWithChildren` 12 | public protocol AnyNodeDescriptionWithChildren: AnyNodeDescription { 13 | /// children of the description 14 | var children: [AnyNodeDescription] { get set } 15 | } 16 | 17 | /** 18 | This is the protocol that descriptions (that is, concrete implementations of `NodeDescription`) need to 19 | implement if they contain children. For example the `View` component of `KatanaElements` implements this 20 | protocol and it allows to implement the following behaviour: 21 | 22 | ``` 23 | func childrenDescriptions(...) { 24 | View(props: ViewProps()) { 25 | return [ 26 | Image(props: ImageProps()), 27 | Button(props: ButtonProps()), 28 | ] 29 | } 30 | } 31 | ``` 32 | Basically we can return a View description that holds some children. 33 | 34 | Katana will internally leverage this protocol to correctly handle the children (e.g., render and apply the layout). 35 | */ 36 | /// Props should implement `Childrenable` 37 | public protocol NodeDescriptionWithChildren: NodeDescription, AnyNodeDescriptionWithChildren where PropsType: Childrenable { 38 | } 39 | 40 | public extension NodeDescriptionWithChildren { 41 | /// the default implementation is a proxy for `props.children` 42 | public var children: [AnyNodeDescription] { 43 | get { 44 | return self.props.children 45 | } 46 | 47 | set(newValue) { 48 | self.props.children = newValue 49 | } 50 | } 51 | } 52 | 53 | /// This protocol is used for properties of descriptions that implement the `NodeDescriptionWithChildren` protocol 54 | public protocol Childrenable { 55 | /// The children of the description 56 | var children: [AnyNodeDescription] { get set } 57 | } 58 | -------------------------------------------------------------------------------- /KatanaUI/Core/Animations/AnimationContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimationContainer.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | /** 12 | A container for animations. 13 | It contains both the animation to perform when the native view is updated and also 14 | the animations to use for children 15 | */ 16 | public struct AnimationContainer { 17 | /// The animation to use for the native view 18 | let nativeViewAnimation: AnimationType 19 | 20 | /// The animations for the children 21 | let childrenAnimation: AnyChildrenAnimations 22 | 23 | /// An empty animation 24 | public static let none = AnimationContainer(nativeViewAnimation: .none, childrenAnimation: ChildrenAnimations()) 25 | 26 | /** 27 | Creates a container with the given values 28 | 29 | - parameter nativeViewAnimation: the animation to perform when the native view is updated 30 | - parameter childrenAnimation: the animation of the children 31 | */ 32 | init(nativeViewAnimation: AnimationType, childrenAnimation: AnyChildrenAnimations) { 33 | self.nativeViewAnimation = nativeViewAnimation 34 | self.childrenAnimation = childrenAnimation 35 | } 36 | 37 | /** 38 | Creates an instance of `AnimationContainer` for a given child 39 | 40 | - parameter child: the child 41 | - returns: an `AnimationContainer` that can be used to animate the child 42 | */ 43 | func animation(for child: AnyNodeDescription) -> AnimationContainer { 44 | // If the child implements the NodeDescriptionWithChildren protocol, then we need 45 | // to forward the animations down in the hierarchy 46 | let childChildrenAnimation = child is AnyNodeDescriptionWithChildren 47 | ? self.childrenAnimation 48 | : ChildrenAnimations() 49 | 50 | return AnimationContainer( 51 | nativeViewAnimation: self.childrenAnimation[child].type, 52 | childrenAnimation: childChildrenAnimation 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /KatanaUI/Plastic/CoordinateConvertible.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoordinateConvertible.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import CoreGraphics 10 | 11 | /** 12 | Protocol used to emulate the hierarchy methods that UIKit/AppKit offers. 13 | Whoever implements this method should act as UIKit/AppKit and provide conversion 14 | from relative to absolute coordinate. With absolute coordinate system we mean 15 | the coordinate system of the native view of a `NodeDescription` 16 | 17 | UIKit/AppKit offers method to translate coordinates from a coordinate system to another, we don't need all 18 | his freedom since our use case is way more restricted 19 | */ 20 | protocol CoordinateConvertible: class { 21 | /** 22 | Given the X coordinate in the absolute coordinate system (that is, the coordinate of the 23 | native view), this method returns the X coordinate in the coordinate system of the view 24 | represented with the given key. 25 | 26 | - parameter absoluteValue: the X coordinate in the absolute coordinate system 27 | - parameter key: the key of the view that will be used to calculate the relative X coordinate 28 | 29 | - returns: the X coordinate in the `key` view coordinate system 30 | */ 31 | func getXCoordinate(_ absoluteValue: CGFloat, inCoordinateSystemOfParentOfKey key: String) -> CGFloat 32 | 33 | /** 34 | Given the Y coordinate in the absolute coordinate system (that is, the coordinate of the 35 | native view), this method returns the X coordinate in the coordinate system of the view 36 | represented with the given key. 37 | 38 | - parameter absoluteValue: the Y coordinate in the absolute coordinate system 39 | - parameter key: the key of the view that will be used to calculate the relative X coordinate 40 | 41 | - returns: the X coordinate in the `key` view coordinate system 42 | */ 43 | func getYCoordinate(_ absoluteValue: CGFloat, inCoordinateSystemOfParentOfKey key: String) -> CGFloat 44 | } 45 | -------------------------------------------------------------------------------- /Examples/CodingLove/CodingLove/Providers/PostsProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostsProvider.swift 3 | // KatanaCodingLove 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import Katana 11 | 12 | let postsPerPage = 3 13 | 14 | class PostsProvider: SideEffectDependencyContainer { 15 | public required init(dispatch: @escaping StoreDispatch, getState: @escaping () -> State) {} 16 | 17 | public func fetchPosts(for page: Int, completion: @escaping (([Post], Bool)?, String?) -> ()) { 18 | DispatchQueue.global().async { 19 | let posts = Bundle.main.path(forResource: "posts", ofType: "json") 20 | .flatMap { URL(fileURLWithPath: $0) } 21 | .flatMap { try? Data(contentsOf: $0) } 22 | .flatMap { try? JSONSerialization.jsonObject(with: $0, options: []) } 23 | .flatMap { $0 as? [[String: String]] } 24 | 25 | if let actualPosts = posts { 26 | var allFetched = false 27 | if ((page+1) * postsPerPage) >= posts!.count { 28 | allFetched = true 29 | } 30 | 31 | let filteredPosts = Array(actualPosts[(page * postsPerPage)..<((page+1) * postsPerPage)]) 32 | completion((self.parsePosts(postsToParse: filteredPosts), allFetched), nil) 33 | 34 | } else { 35 | completion(nil, "Could not fetch posts") 36 | } 37 | } 38 | } 39 | 40 | private func parsePosts(postsToParse: [[String: String]]) -> [Post] { 41 | var result = [Post]() 42 | for post in postsToParse { 43 | let title = post["title"] 44 | 45 | guard let imageUrl = URL(string: post["image_url"]!) else { 46 | continue // Fail silently 47 | } 48 | 49 | guard let imageData = try? Data(contentsOf: imageUrl) else { 50 | continue // Fail silently 51 | } 52 | 53 | result.append(Post(title: title!, imageData: imageData)) 54 | } 55 | 56 | return result 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /KatanaElements/TouchHandler/TouchHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TouchHandler.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import UIKit 10 | import KatanaUI 11 | import Katana 12 | 13 | public typealias TouchHandlerClosure = () -> () 14 | 15 | public extension TouchHandler { 16 | public struct Props: NodeDescriptionProps, Childrenable, Buildable { 17 | public var frame = CGRect.zero 18 | public var key: String? 19 | public var alpha: CGFloat = 1.0 20 | 21 | public var children: [AnyNodeDescription] = [] 22 | public var handlers: [TouchHandlerEvent: TouchHandlerClosure]? 23 | public var hitTestInsets: UIEdgeInsets = .zero 24 | 25 | public init() {} 26 | 27 | public static func == (lhs: Props, rhs: Props) -> Bool { 28 | // always re render, we haven't found a decent way to compare handlers so far 29 | return false 30 | } 31 | } 32 | } 33 | 34 | public struct TouchHandler: NodeDescription, NodeDescriptionWithChildren { 35 | public typealias NativeView = NativeTouchHandler 36 | 37 | public var props: Props 38 | 39 | public static func applyPropsToNativeView(props: Props, 40 | state: EmptyState, 41 | view: NativeTouchHandler, 42 | update: @escaping (EmptyState)->(), 43 | node: AnyNode) { 44 | view.frame = props.frame 45 | view.alpha = props.alpha 46 | view.handlers = props.handlers 47 | view.hitTestInsets = props.hitTestInsets 48 | } 49 | 50 | public static func childrenDescriptions(props: Props, 51 | state: EmptyState, 52 | update: @escaping (EmptyState)->(), 53 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 54 | 55 | return props.children 56 | } 57 | 58 | public init(props: Props) { 59 | self.props = props 60 | } 61 | 62 | public init(props: Props, _ children: () -> [AnyNodeDescription]) { 63 | self.props = props 64 | self.props.children = children() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /KatanaTests/NodeDescriptions/View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import UIKit 11 | import KatanaUI 12 | import Katana 13 | 14 | struct ViewProps: NodeDescriptionProps, Childrenable { 15 | var frame = CGRect.zero 16 | var key: String? 17 | var alpha: CGFloat = 1.0 18 | 19 | var children: [AnyNodeDescription] = [] 20 | 21 | var backgroundColor = UIColor.white 22 | 23 | init() {} 24 | 25 | init(_ key: Key) { 26 | self.setKey(key) 27 | } 28 | 29 | init(_ key: Key, frame: CGRect) { 30 | self.setKey(key) 31 | self.frame = frame 32 | } 33 | 34 | init(_ frame: CGRect) { 35 | self.frame = frame 36 | } 37 | 38 | static func == (lhs: ViewProps, rhs: ViewProps) -> Bool { 39 | if lhs.children.count + rhs.children.count > 1 { 40 | return false 41 | } 42 | 43 | return 44 | lhs.frame == rhs.frame && 45 | lhs.key == rhs.key && 46 | lhs.alpha == rhs.alpha && 47 | lhs.backgroundColor == rhs.backgroundColor 48 | } 49 | } 50 | 51 | struct View: NodeDescription, NodeDescriptionWithChildren { 52 | 53 | public var props: ViewProps 54 | 55 | public static func applyPropsToNativeView(props: ViewProps, 56 | state: EmptyState, 57 | view: UIView, 58 | update: @escaping (EmptyState)->(), 59 | node: AnyNode) { 60 | 61 | view.frame = props.frame 62 | view.backgroundColor = props.backgroundColor 63 | } 64 | 65 | public static func childrenDescriptions(props: ViewProps, 66 | state: EmptyState, 67 | update: @escaping (EmptyState)->(), 68 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 69 | return props.children 70 | } 71 | 72 | public init(props: ViewProps) { 73 | self.props = props 74 | } 75 | 76 | public init(props: ViewProps, _ children: () -> [AnyNodeDescription]) { 77 | self.props = props 78 | self.props.children = children() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /KatanaUI/Core/Animations/Animation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Animation.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | /** 12 | The animation for a child of a `NodeDescription`. 13 | 14 | The idea is that, for elements that are either created or destroyed during an animation, 15 | we will compute initial or final state and then animate to/from there. 16 | 17 | When an element is created during an animated update, we take the final state (that is, the state 18 | you have returned in the `childrenDescription` method) and we apply the transformers 19 | you have specified in `entryTransformers`. 20 | The resulting props are used to render an intermediated state that will be animated to the final state. 21 | 22 | When an element is destroyed during an animated update, something similar happens. The only difference is that 23 | we take the initial state (that is, the state of the last render when the element was present) and we apply the 24 | transformers you have specified in `leaveTransformers` 25 | */ 26 | public struct Animation { 27 | /// The animation type to perform for the child 28 | let type: AnimationType 29 | 30 | /// The entry phase transformers 31 | let entryTransformers: [AnimationPropsTransformer] 32 | 33 | /// The leave entry phase transformers 34 | let leaveTransformers: [AnimationPropsTransformer] 35 | 36 | /// An empty animation 37 | static let none = Animation(type: .none) 38 | 39 | /** 40 | Creates an animation with the given values 41 | 42 | - parameter type: the type of animation to apply to the child 43 | - parameter entryTransformers: the transformers to use in the entry phase 44 | - parameter leaveTransformers: the transformers to use in the leave phase 45 | - returns: an animation with the given parameters 46 | */ 47 | public init(type: AnimationType, 48 | entryTransformers: [AnimationPropsTransformer], 49 | leaveTransformers: [AnimationPropsTransformer]) { 50 | 51 | self.type = type 52 | self.entryTransformers = entryTransformers 53 | self.leaveTransformers = leaveTransformers 54 | } 55 | 56 | /** 57 | Creates an animation with the given values 58 | 59 | - parameter type: the type of animation to apply to the child 60 | - returns: an animation with the given animation type and no transformers 61 | */ 62 | public init(type: AnimationType) { 63 | self.type = type 64 | self.entryTransformers = [] 65 | self.leaveTransformers = [] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /KatanaElements/TouchHandler/NativeTouchHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NativeTouchHandler.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | public final class NativeTouchHandler: UIControl { 13 | var hitTestInsets: UIEdgeInsets = .zero 14 | 15 | var handlers: [TouchHandlerEvent: TouchHandlerClosure]? { 16 | didSet { 17 | self.update() 18 | } 19 | } 20 | 21 | override init(frame: CGRect) { 22 | super.init(frame: frame) 23 | self.backgroundColor = .clear 24 | } 25 | 26 | public required init?(coder aDecoder: NSCoder) { 27 | fatalError("init(coder:) has not been implemented") 28 | } 29 | 30 | func update() { 31 | self.removeTarget(nil, action: nil, for: .allEvents) 32 | 33 | guard let handlers = self.handlers else { return } 34 | 35 | for (event, _) in handlers { 36 | if event.contains(.touchDown) { 37 | self.addTarget(self, action: #selector(didTouchDown), for: .touchDown) 38 | } 39 | 40 | if event.contains(.touchUpInside) { 41 | self.addTarget(self, action: #selector(didTouchUpInside), for: .touchUpInside) 42 | } 43 | 44 | if event.contains(.touchUpOutside) { 45 | self.addTarget(self, action: #selector(didTouchUpOutside), for: .touchUpOutside) 46 | } 47 | 48 | if event.contains(.touchCancel) { 49 | self.addTarget(self, action: #selector(didTouchCancel), for: .touchCancel) 50 | } 51 | } 52 | } 53 | 54 | @objc func didTouchDown() { 55 | self.invokeAllHandlers(for: .touchDown) 56 | } 57 | 58 | @objc func didTouchUpInside() { 59 | self.invokeAllHandlers(for: .touchUpInside) 60 | } 61 | 62 | @objc func didTouchUpOutside() { 63 | self.invokeAllHandlers(for: .touchUpOutside) 64 | } 65 | 66 | @objc func didTouchCancel() { 67 | self.invokeAllHandlers(for: .touchCancel) 68 | } 69 | 70 | private func invokeAllHandlers(for event: TouchHandlerEvent) { 71 | guard let handlers = self.handlers else { return } 72 | 73 | let eventHandlers = handlers.filter { $0.key.contains(event) } 74 | 75 | for (_, closure) in eventHandlers { 76 | closure() 77 | } 78 | } 79 | 80 | public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { 81 | if self.hitTestInsets == .zero || self.isHidden { 82 | return super.point(inside: point, with: event) 83 | } 84 | 85 | let hitFrame = UIEdgeInsetsInsetRect(self.bounds, self.hitTestInsets) 86 | return hitFrame.contains(point) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Examples/CodingLove/CodingLove/Actions/FetchMorePosts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadPosts.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import Katana 11 | 12 | struct FetchMorePosts: AsyncAction, ActionWithSideEffect { 13 | public struct CompletedActionPayload { 14 | var posts: [Post] 15 | var allFetched: Bool = false 16 | } 17 | 18 | typealias LoadingPayload = Void 19 | typealias CompletedPayload = CompletedActionPayload 20 | typealias FailedPayload = String 21 | 22 | public var loadingPayload: LoadingPayload 23 | public var completedPayload: CompletedPayload? 24 | public var failedPayload: FailedPayload? 25 | 26 | public var state: AsyncActionState = .loading 27 | 28 | init(payload: LoadingPayload) { 29 | self.loadingPayload = payload 30 | } 31 | 32 | func updatedStateForLoading(currentState: State) -> State { 33 | var newState = currentState as! CodingLoveState 34 | newState.loading = true 35 | newState.page += 1 36 | return newState 37 | } 38 | 39 | func updatedStateForCompleted(currentState: State) -> State { 40 | var newState = currentState as! CodingLoveState 41 | newState.loading = false 42 | newState.posts += (self.completedPayload?.posts)! 43 | newState.allPostsFetched = (self.completedPayload?.allFetched)! 44 | return newState 45 | } 46 | 47 | func updatedStateForFailed(currentState: State) -> State { 48 | var newState = currentState as! CodingLoveState 49 | newState.loading = false 50 | return newState 51 | } 52 | 53 | func updatedStateForProgress(currentState: State) -> State { 54 | return currentState 55 | } 56 | 57 | public func sideEffect( 58 | currentState: State, 59 | previousState: State, 60 | dispatch: @escaping StoreDispatch, 61 | dependencies: SideEffectDependencyContainer) { 62 | 63 | let castedState = currentState as! CodingLoveState 64 | let page: Int = castedState.page 65 | 66 | let postsProvider = dependencies as! PostsProvider 67 | 68 | postsProvider.fetchPosts(for: page) { (result, errorMessage) in 69 | if let data = result { 70 | let (posts, allFetched) = data 71 | dispatch(self.completedAction { 72 | $0.completedPayload = CompletedActionPayload(posts: posts, allFetched: allFetched) 73 | }) 74 | 75 | } else { 76 | dispatch(self.failedAction { $0.failedPayload = errorMessage! }) 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /KatanaElements/Table/Table.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Table.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import UIKit 11 | import KatanaUI 12 | import Katana 13 | 14 | public extension Table { 15 | public struct Props: NodeDescriptionProps, NodeDescriptionWithRefProps, Buildable { 16 | public var frame = CGRect.zero 17 | public var key: String? 18 | public var alpha: CGFloat = 1.0 19 | 20 | public var delegate: TableDelegate = EmptyTableDelegate() 21 | public var backgroundColor = UIColor.white 22 | public var cornerRadius: CGFloat = 0.0 23 | public var borderWidth: CGFloat = 0.0 24 | public var borderColor = UIColor.clear 25 | public var clipsToBounds = true 26 | public var refCallback: RefCallbackClosure? 27 | 28 | public init() {} 29 | 30 | public static func == (lhs: Props, rhs: Props) -> Bool { 31 | return 32 | lhs.frame == rhs.frame && 33 | lhs.key == rhs.key && 34 | lhs.alpha == rhs.alpha && 35 | lhs.backgroundColor == rhs.backgroundColor && 36 | lhs.cornerRadius == rhs.cornerRadius && 37 | lhs.borderWidth == rhs.borderWidth && 38 | lhs.borderColor == rhs.borderColor && 39 | lhs.clipsToBounds == rhs.clipsToBounds && 40 | lhs.delegate.isEqual(to: rhs.delegate) 41 | } 42 | } 43 | } 44 | 45 | public struct Table: NodeDescription, NodeDescriptionWithRef { 46 | public typealias NativeView = NativeTable 47 | 48 | public var props: Props 49 | 50 | public init(props: Props) { 51 | self.props = props 52 | } 53 | 54 | public static func applyPropsToNativeView(props: Props, 55 | state: EmptyState, 56 | view: NativeTable, 57 | update: @escaping (EmptyState)->(), 58 | node: AnyNode) { 59 | 60 | view.frame = props.frame 61 | view.alpha = props.alpha 62 | view.backgroundColor = props.backgroundColor 63 | view.layer.cornerRadius = props.cornerRadius 64 | view.layer.borderWidth = props.borderWidth 65 | view.layer.borderColor = props.borderColor.cgColor 66 | view.clipsToBounds = props.clipsToBounds 67 | view.update(withparent: node, delegate: props.delegate) 68 | } 69 | 70 | public static func childrenDescriptions(props: Props, 71 | state: EmptyState, 72 | update: @escaping (EmptyState)->(), 73 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 74 | return [] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /KatanaElements/NativeButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NativeButton.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | public class NativeButton: UIButton { 12 | 13 | public typealias ButtonEvent = (NativeButton) -> () 14 | 15 | public var touchHandlers: [TouchHandlerEvent: TouchHandlerClosure] = [:] { 16 | didSet { 17 | updateTargets() 18 | } 19 | } 20 | 21 | private func updateTargets() { 22 | self.didTouchUpInside = touchHandlers[.touchUpInside] 23 | self.didTouchDown = touchHandlers[.touchDown] 24 | self.didTouchUpOutside = touchHandlers[.touchUpOutside] 25 | self.didTouchCancel = touchHandlers[.touchCancel] 26 | } 27 | 28 | public var didTouchUpInside: TouchHandlerClosure? { 29 | didSet { 30 | if didTouchUpInside != nil { 31 | addTarget(self, action: #selector(didTouchUpInside(sender:)), for: .touchUpInside) 32 | } else { 33 | removeTarget(self, action: #selector(didTouchUpInside(sender:)), for: .touchUpInside) 34 | } 35 | } 36 | } 37 | 38 | public var didTouchDown: TouchHandlerClosure? { 39 | didSet { 40 | if didTouchDown != nil { 41 | addTarget(self, action: #selector(didTouchDown(sender:)), for: .touchDown) 42 | } else { 43 | removeTarget(self, action: #selector(didTouchDown(sender:)), for: .touchDown) 44 | } 45 | } 46 | } 47 | 48 | public var didTouchUpOutside: TouchHandlerClosure? { 49 | didSet { 50 | if didTouchUpOutside != nil { 51 | addTarget(self, action: #selector(didTouchUpOutside(sender:)), for: .touchUpOutside) 52 | } else { 53 | removeTarget(self, action: #selector(didTouchUpOutside(sender:)), for: .touchUpOutside) 54 | } 55 | } 56 | } 57 | 58 | public var didTouchCancel: TouchHandlerClosure? { 59 | didSet { 60 | if didTouchCancel != nil { 61 | addTarget(self, action: #selector(didTouchCancel(sender:)), for: .touchCancel) 62 | } else { 63 | removeTarget(self, action: #selector(didTouchCancel(sender:)), for: .touchCancel) 64 | } 65 | } 66 | } 67 | 68 | // MARK: - Actions 69 | @objc private func didTouchUpInside(sender: NativeButton) { 70 | if let handler = didTouchUpInside { 71 | handler() 72 | } 73 | } 74 | 75 | @objc private func didTouchDown(sender: NativeButton) { 76 | if let handler = didTouchDown { 77 | handler() 78 | } 79 | } 80 | 81 | @objc private func didTouchCancel(sender: NativeButton) { 82 | if let handler = didTouchCancel { 83 | handler() 84 | } 85 | } 86 | 87 | @objc private func didTouchUpOutside(sender: NativeButton) { 88 | if let handler = didTouchUpOutside { 89 | handler() 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Examples/CodingLove/CodingLove/UI/PostCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostCell.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import Katana 11 | import KatanaUI 12 | import KatanaElements 13 | 14 | extension PostCell { 15 | enum Keys { 16 | case titleLabel 17 | case gifImage 18 | } 19 | 20 | struct Props: NodeDescriptionProps, Buildable { 21 | var frame: CGRect = .zero 22 | var alpha: CGFloat = 1.0 23 | var key: String? = nil 24 | var index: Int = 0 25 | var post: Post? = nil 26 | } 27 | } 28 | 29 | struct PostCell: PlasticNodeDescription, PlasticReferenceSizeable, TableCell, ConnectedNodeDescription { 30 | typealias StateType = EmptyHighlightableState 31 | typealias PropsType = Props 32 | typealias NativeView = NativeTableCell 33 | 34 | var props: Props 35 | 36 | static var referenceSize = CGSize(width: 640, height: 500) 37 | 38 | static func childrenDescriptions(props: PropsType, 39 | state: StateType, 40 | update: @escaping (StateType)->(), 41 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 42 | return [ 43 | Label(props: Label.Props.build({ 44 | $0.setKey(Keys.titleLabel) 45 | $0.text = NSAttributedString(string: (props.post?.title)!, attributes: [ 46 | NSFontAttributeName: UIFont.systemFont(ofSize: 18) 47 | ]) 48 | $0.textAlignment = NSTextAlignment.center 49 | $0.numberOfLines = 0 50 | })), 51 | Image(props: Image.Props.build({ 52 | $0.setKey(Keys.gifImage) 53 | $0.image = UIImage.gif(data: (props.post?.imageData)!) 54 | $0.backgroundColor = UIColor.lightGray 55 | })) 56 | ] 57 | } 58 | 59 | static func layout(views: ViewsContainer, props: Props, state: EmptyHighlightableState) { 60 | let rootView = views.nativeView 61 | let title = views[Keys.titleLabel]! 62 | let imageView = views[Keys.gifImage]! 63 | 64 | title.asHeader(rootView, insets: .scalable(30, 10, 10, 5)) 65 | title.height = .scalable(70) 66 | 67 | imageView.fillHorizontally(rootView) 68 | imageView.top = title.bottom 69 | imageView.bottom = rootView.bottom 70 | } 71 | 72 | static func didTap(dispatch: StoreDispatch, props: PropsType, indexPath: IndexPath) { 73 | // Do nothing 74 | } 75 | 76 | static func connect(props: inout Props, to storeState: CodingLoveState) { 77 | props.post = storeState.posts[props.index] 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/UI/MinesweeperGrid.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinesweeperGrid.swift 3 | // Minesweeper 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Katana 10 | import KatanaUI 11 | import KatanaElements 12 | 13 | // MARK: - NodeDescription 14 | struct MinesweeperGrid: ConnectedNodeDescription, PlasticNodeDescription { 15 | typealias StateType = EmptyState 16 | typealias PropsType = Props 17 | typealias NativeView = UIView 18 | typealias Keys = ChildrenKeys 19 | 20 | var props: Props 21 | 22 | static func childrenDescriptions(props: PropsType, 23 | state: StateType, 24 | update: @escaping (StateType) -> (), 25 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 26 | 27 | var nodes = [AnyNodeDescription]() 28 | for column in 0.., props: PropsType, state: StateType) { 42 | let root = views.nativeView 43 | var leftAnchor = root.left 44 | var topAnchor = root.top 45 | 46 | for column in 0.. Bool { 82 | return 83 | lhs.frame == rhs.frame && 84 | lhs.alpha == rhs.alpha && 85 | lhs.key == rhs.key && 86 | lhs.cols == rhs.cols && 87 | lhs.rows == rhs.rows 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/UI/Minesweeper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Minesweeper.swift 3 | // Minesweeper 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Katana 10 | import KatanaUI 11 | import KatanaElements 12 | 13 | // MARK: - NodeDescription 14 | struct Minesweeper: NodeDescription, ConnectedNodeDescription, PlasticNodeDescription, PlasticReferenceSizeable { 15 | typealias StateType = EmptyState 16 | typealias PropsType = Props 17 | typealias NativeView = UIView 18 | typealias Keys = ChildrenKeys 19 | 20 | var props: Props 21 | static var referenceSize: CGSize = CGSize(width: 750, height: 1334) 22 | 23 | static func childrenDescriptions(props: PropsType, 24 | state: StateType, 25 | update: @escaping (StateType) -> (), 26 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 27 | 28 | func buttonTap() { 29 | dispatch(StartGame(payload: .hard)) 30 | } 31 | 32 | let text = props.gameover ? "gameover :( tap to restart" : ":)" 33 | 34 | let button = Button(props: Button.Props.build({ 35 | $0.setKey(Keys.title) 36 | $0.titles = [.normal: text] 37 | $0.borderColor = .darkGray 38 | $0.borderWidth = .fixed(2) 39 | $0.titleColors = [.normal: .black, .highlighted: .lightGray] 40 | $0.touchHandlers = [.touchUpInside: buttonTap] 41 | })) 42 | 43 | let minesweeperGrid = MinesweeperGrid(props: MinesweeperGrid.Props.build({ 44 | $0.setKey(Keys.field) 45 | })) 46 | 47 | return [button, minesweeperGrid] 48 | } 49 | 50 | static func layout(views: ViewsContainer, props: PropsType, state: StateType) { 51 | let root = views.nativeView 52 | let title = views[.title]! 53 | let field = views[.field]! 54 | 55 | title.asHeader(root, insets: .scalable(30, 0, 0, 0)) 56 | title.height = .scalable(60) 57 | 58 | field.fillHorizontally(root) 59 | field.top = title.bottom 60 | field.bottom = root.bottom 61 | } 62 | 63 | static func connect(props: inout PropsType, to storeState: MinesweeperState) { 64 | props.gameover = storeState.gameOver 65 | } 66 | } 67 | 68 | // MARK: Keys and Props 69 | extension Minesweeper { 70 | enum ChildrenKeys { 71 | case title, field 72 | } 73 | 74 | struct Props: NodeDescriptionProps, Buildable { 75 | public var frame: CGRect = .zero 76 | public var alpha: CGFloat = 1.0 77 | public var key: String? 78 | 79 | var gameover: Bool = false 80 | 81 | static func == (lhs: PropsType, rhs: PropsType) -> Bool { 82 | return 83 | lhs.frame == rhs.frame && 84 | lhs.alpha == rhs.alpha && 85 | lhs.gameover == rhs.gameover 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /KatanaTests/Core/StateMockProviderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateMockProviderTests.swift 3 | // Katana 4 | // 5 | // Created by Mauro Bolis on 02/04/2017. 6 | // Copyright © 2017 Bending Spoons. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | import UIKit 12 | import Katana 13 | @testable import KatanaUI 14 | 15 | fileprivate struct CustomState: NodeDescriptionState { 16 | let int: Int 17 | 18 | init(int: Int) { 19 | self.int = int 20 | } 21 | 22 | init() { 23 | self.int = 99 24 | } 25 | 26 | static func == (l: CustomState, r: CustomState) -> Bool { 27 | return l.int == r.int 28 | } 29 | } 30 | 31 | fileprivate struct CustomProps: NodeDescriptionProps { 32 | var frame: CGRect = CGRect.zero 33 | var key: String? 34 | var alpha: CGFloat = 1.0 35 | 36 | var i: Int 37 | 38 | init(i: Int) { 39 | self.i = i 40 | } 41 | } 42 | 43 | fileprivate struct CustomDescription: NodeDescription { 44 | typealias NativeView = UIView 45 | typealias StateType = CustomState 46 | typealias PropsType = CustomProps 47 | 48 | var props: PropsType 49 | 50 | init(props: PropsType) { 51 | self.props = props 52 | } 53 | 54 | public static func childrenDescriptions(props: PropsType, 55 | state: StateType, 56 | update: @escaping (StateType) -> (), 57 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 58 | 59 | return [] 60 | } 61 | } 62 | 63 | class StateMockProviderTests: XCTestCase { 64 | func testBase() { 65 | let provider = StateMockProvider() 66 | 67 | provider.mockState(CustomState(int: 101), for: CustomDescription.self, passing: { 68 | return $0.i == 1000 69 | }) 70 | 71 | let state = provider.state(for: CustomDescription.self, props: CustomProps(i: 1000)) 72 | XCTAssertEqual(state, CustomState(int: 101)) 73 | } 74 | 75 | func testNotFound() { 76 | let provider = StateMockProvider() 77 | 78 | provider.mockState(CustomState(int: 101), for: CustomDescription.self, passing: { 79 | return $0.i == 90 80 | }) 81 | 82 | let state = provider.state(for: CustomDescription.self, props: CustomProps(i: 1000)) 83 | XCTAssertNil(state) 84 | } 85 | 86 | func testTakeFirst() { 87 | let provider = StateMockProvider() 88 | 89 | provider.mockState(CustomState(int: 100), for: CustomDescription.self, passing: { 90 | return $0.i == 101 91 | }) 92 | 93 | provider.mockState(CustomState(int: 99), for: CustomDescription.self, passing: { 94 | return $0.i == 101 95 | }) 96 | 97 | let state = provider.state(for: CustomDescription.self, props: CustomProps(i: 101)) 98 | XCTAssertEqual(state, CustomState(int: 100)) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /KatanaElements/Image.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Image.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import UIKit 10 | import KatanaUI 11 | import Katana 12 | 13 | public extension Image { 14 | public struct Props: NodeDescriptionProps, Buildable { 15 | public var frame = CGRect.zero 16 | public var key: String? 17 | public var alpha: CGFloat = 1.0 18 | 19 | public var backgroundColor = UIColor.white 20 | public var cornerRadius: Value = .zero 21 | public var borderWidth: Value = .zero 22 | public var borderColor = UIColor.clear 23 | public var clipsToBounds = true 24 | public var isUserInteractionEnabled = false 25 | public var image: UIImage? = nil 26 | public var tintColor: UIColor = .clear 27 | 28 | public init() {} 29 | 30 | public static func == (lhs: Props, rhs: Props) -> Bool { 31 | return 32 | lhs.frame == rhs.frame && 33 | lhs.key == rhs.key && 34 | lhs.alpha == rhs.alpha && 35 | lhs.backgroundColor == rhs.backgroundColor && 36 | lhs.cornerRadius == rhs.cornerRadius && 37 | lhs.borderWidth == rhs.borderWidth && 38 | lhs.borderColor == rhs.borderColor && 39 | lhs.clipsToBounds == rhs.clipsToBounds && 40 | lhs.isUserInteractionEnabled == rhs.isUserInteractionEnabled && 41 | lhs.image == rhs.image && 42 | lhs.tintColor == rhs.tintColor 43 | } 44 | } 45 | } 46 | 47 | public struct Image: NodeDescription { 48 | public typealias NativeView = UIImageView 49 | 50 | public var props: Props 51 | 52 | public static func applyPropsToNativeView(props: Props, 53 | state: EmptyState, 54 | view: UIImageView, 55 | update: @escaping (EmptyState)->(), 56 | node: AnyNode) { 57 | 58 | view.frame = props.frame 59 | view.alpha = props.alpha 60 | view.backgroundColor = props.backgroundColor 61 | view.layer.cornerRadius = props.cornerRadius.scale(by: node.plasticMultiplier) 62 | view.layer.borderColor = props.borderColor.cgColor 63 | view.layer.borderWidth = props.borderWidth.scale(by: node.plasticMultiplier) 64 | view.clipsToBounds = props.clipsToBounds 65 | view.isUserInteractionEnabled = props.isUserInteractionEnabled 66 | view.image = props.image 67 | view.tintColor = props.tintColor 68 | } 69 | 70 | public static func childrenDescriptions(props: Props, 71 | state: EmptyState, 72 | update: @escaping (EmptyState)->(), 73 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 74 | return [] 75 | } 76 | 77 | public init(props: Props) { 78 | self.props = props 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Examples/CodingLove/CodingLove/UI/FetchMoreCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchMoreCell.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import Katana 11 | import KatanaUI 12 | import KatanaElements 13 | 14 | extension FetchMoreCell { 15 | enum Keys: String { 16 | case label 17 | } 18 | 19 | struct Props: NodeDescriptionProps { 20 | var key: String? = nil 21 | var alpha: CGFloat = 1.0 22 | var frame: CGRect = .zero 23 | var loading: Bool = true 24 | var allPostsFetched: Bool = false 25 | } 26 | } 27 | 28 | struct FetchMoreCell: PlasticNodeDescription, PlasticReferenceSizeable, TableCell, ConnectedNodeDescription { 29 | typealias StateType = EmptyHighlightableState 30 | typealias PropsType = Props 31 | typealias NativeView = NativeTableCell 32 | 33 | var props: Props 34 | 35 | static var referenceSize = CGSize(width: 640, height: 200) 36 | 37 | static func childrenDescriptions(props: PropsType, 38 | state: StateType, 39 | update: @escaping (StateType)->(), 40 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 41 | 42 | var labelText = "Fetch More" 43 | if props.loading { 44 | labelText = "Loading..." 45 | } else if props.allPostsFetched { 46 | labelText = "No more available" 47 | } 48 | 49 | var labelBackgroundColor = UIColor.white 50 | if state.highlighted { 51 | labelBackgroundColor = UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1) 52 | } 53 | 54 | return [ 55 | Label(props: Label.Props.build({ 56 | $0.key = Keys.label.rawValue 57 | $0.text = NSAttributedString(string: labelText, attributes: [ 58 | NSFontAttributeName: UIFont.systemFont(ofSize: 16) 59 | ]) 60 | $0.textAlignment = .center 61 | $0.backgroundColor = labelBackgroundColor 62 | })) 63 | ] 64 | } 65 | 66 | static func layout(views: ViewsContainer, props: Props, state: EmptyHighlightableState) { 67 | let rootView = views.nativeView 68 | let title = views[Keys.label]! 69 | 70 | title.fill(rootView) 71 | } 72 | 73 | static func didTap(dispatch: StoreDispatch, props: Props, indexPath: IndexPath) { 74 | if props.allPostsFetched { 75 | return 76 | } 77 | 78 | dispatch(FetchMorePosts(payload: ())) 79 | } 80 | 81 | static func connect(props: inout Props, to storeState: CodingLoveState) { 82 | props.loading = storeState.loading 83 | props.allPostsFetched = storeState.allPostsFetched 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /KatanaUI/Core/Animations/AnimationPropsTransfomer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimationPropsTransfomer.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import CoreGraphics 11 | 12 | /** 13 | The transformer function used to update properties to perform entry and leave animations. 14 | The idea is that props are changed by chaining different transformers. 15 | */ 16 | public typealias AnimationPropsTransformer = (_ props: AnyNodeDescriptionProps) -> AnyNodeDescriptionProps 17 | 18 | /// Built in implementations of `AnimationPropsTransformer` 19 | public struct AnimationProps { 20 | private init() {} 21 | 22 | /** 23 | Scales the size of the element 24 | 25 | - parameter percentage: value used to define how much the element is scaled 26 | - returns: an `AnimationPropsTransformer` that scales the size of the element 27 | */ 28 | public static func scale(percentage: CGFloat) -> AnimationPropsTransformer { 29 | return { 30 | var p = $0 31 | 32 | p.frame.size = CGSize( 33 | width: $0.frame.size.width * percentage, 34 | height: $0.frame.size.height * percentage 35 | ) 36 | 37 | p.frame.origin = CGPoint( 38 | x: $0.frame.origin.x + $0.frame.size.width / 2.0, 39 | y: $0.frame.origin.y + $0.frame.size.height / 2.0 40 | ) 41 | 42 | return p 43 | } 44 | } 45 | 46 | /** 47 | Moves the element to the left 48 | 49 | - parameter distance: the amount of points by which the element is moved. Given an animation duration, 50 | the grater is the distance, the faster the element is in moving to the final position 51 | 52 | - returns: an `AnimationPropsTransformer` that moves the element 53 | */ 54 | public static func moveLeft(distance: CGFloat = 1000) -> AnimationPropsTransformer { 55 | return { 56 | var p = $0 57 | p.frame.origin.x = p.frame.origin.x - distance 58 | return p 59 | } 60 | } 61 | 62 | /** 63 | Moves the element to the right 64 | 65 | - parameter distance: the amount of points by which the element is moved. Given an animation duration, 66 | the grater is the distance, the faster the element is in moving to the final position 67 | 68 | - returns: an `AnimationPropsTransformer` that moves the element 69 | */ 70 | public static func moveRight(distance: CGFloat = 1000) -> AnimationPropsTransformer { 71 | return { 72 | var p = $0 73 | p.frame.origin.x = p.frame.origin.x + distance 74 | return p 75 | } 76 | } 77 | 78 | /** 79 | Fades the element. If uses in the `entry` context, the result is a `fade in` animation. When used 80 | in a `leave` context, the result is a `fade out` animation. 81 | 82 | - returns: an `AnimationPropsTransformer` that fades the element 83 | */ 84 | public static func fade() -> AnimationPropsTransformer { 85 | return { 86 | var p = $0 87 | p.alpha = 0.0 88 | return p 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /KatanaElements/View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import UIKit 10 | import KatanaUI 11 | import Katana 12 | 13 | public extension View { 14 | public struct Props: NodeDescriptionProps, Childrenable, Buildable { 15 | public var frame = CGRect.zero 16 | public var key: String? 17 | public var alpha: CGFloat = 1.0 18 | 19 | public var children: [AnyNodeDescription] = [] 20 | 21 | public var backgroundColor = UIColor.white 22 | public var cornerRadius: Value = .zero 23 | public var borderWidth: Value = .zero 24 | public var borderColor = UIColor.clear 25 | public var clipsToBounds = false 26 | public var isUserInteractionEnabled = true 27 | 28 | public init() {} 29 | 30 | public static func == (lhs: Props, rhs: Props) -> Bool { 31 | if (lhs.children.count + rhs.children.count) > 0 { 32 | // Heuristic, we always rerender when there is at least 1 child 33 | return false 34 | } 35 | 36 | return 37 | lhs.frame == rhs.frame && 38 | lhs.key == rhs.key && 39 | lhs.alpha == rhs.alpha && 40 | lhs.backgroundColor == rhs.backgroundColor && 41 | lhs.cornerRadius == rhs.cornerRadius && 42 | lhs.borderWidth == rhs.borderWidth && 43 | lhs.borderColor == rhs.borderColor && 44 | lhs.clipsToBounds == rhs.clipsToBounds && 45 | lhs.isUserInteractionEnabled == rhs.isUserInteractionEnabled 46 | } 47 | } 48 | } 49 | 50 | public struct View: NodeDescription, NodeDescriptionWithChildren { 51 | public var props: Props 52 | 53 | public static func applyPropsToNativeView(props: Props, 54 | state: EmptyState, 55 | view: UIView, 56 | update: @escaping (EmptyState)->(), 57 | node: AnyNode) { 58 | 59 | view.alpha = props.alpha 60 | view.frame = props.frame 61 | view.backgroundColor = props.backgroundColor 62 | view.layer.cornerRadius = props.cornerRadius.scale(by: node.plasticMultiplier) 63 | view.layer.borderColor = props.borderColor.cgColor 64 | view.layer.borderWidth = props.borderWidth.scale(by: node.plasticMultiplier) 65 | view.clipsToBounds = props.clipsToBounds 66 | view.isUserInteractionEnabled = props.isUserInteractionEnabled 67 | } 68 | 69 | public static func childrenDescriptions(props: Props, 70 | state: EmptyState, 71 | update: @escaping (EmptyState)->(), 72 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 73 | return props.children 74 | } 75 | 76 | public init(props: Props) { 77 | self.props = props 78 | } 79 | 80 | public init(props: Props, _ children: () -> [AnyNodeDescription]) { 81 | self.props = props 82 | self.props.children = children() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Cakefile: -------------------------------------------------------------------------------- 1 | project.name = "KatanaUI" 2 | 3 | katana = target do |target| 4 | target.name = "KatanaUI" 5 | target.platform = :ios 6 | target.deployment_target = 9.0 7 | target.language = :swift 8 | target.type = :framework 9 | target.include_files = [ 10 | "KatanaUI/Core/**/*.swift", 11 | "KatanaUI/Extensions/**/*.swift", 12 | "KatanaUI/Plastic/**/*.swift" 13 | ] 14 | 15 | target.all_configurations.each do |configuration| 16 | configuration.settings["INFOPLIST_FILE"] = "KatanaUI/Info.plist" 17 | configuration.settings["PRODUCT_NAME"] = "KatanaUI" 18 | configuration.settings["SWIFT_VERSION"] = "4.0" 19 | end 20 | 21 | target.headers_build_phase do |phase| 22 | phase.public << "KatanaUI/Katana.h" 23 | end 24 | 25 | unit_tests_for target do |unit_test| 26 | unit_test.linked_targets = [target] 27 | unit_test.include_files = [ 28 | "KatanaTests/Animations/**/*.swift", 29 | "KatanaTests/Core/**/*.swift", 30 | "KatanaTests/Extensions/**/*.swift", 31 | "KatanaTests/NodeDescriptions/**/*.swift", 32 | "KatanaTests/Plastic/**/*.swift" 33 | ] 34 | 35 | unit_test.all_configurations.each do |configuration| 36 | configuration.settings["INFOPLIST_FILE"] = "KatanaTests/Info.plist" 37 | configuration.settings["SWIFT_VERSION"] = "4.0" 38 | end 39 | end 40 | 41 | target.scheme(target.name) 42 | end 43 | 44 | 45 | # Katana Elements iOS Framework target 46 | katana_elements = target do |target| 47 | target.name = "KatanaElements" 48 | target.platform = :ios 49 | target.deployment_target = 9.0 50 | target.language = :swift 51 | target.type = :framework 52 | target.linked_targets = [katana] 53 | target.include_files = [ 54 | "KatanaElements/**/*.swift" 55 | ] 56 | 57 | target.all_configurations.each do |configuration| 58 | configuration.settings["INFOPLIST_FILE"] = "KatanaElements/Info.plist" 59 | configuration.settings["PRODUCT_NAME"] = "KatanaElements" 60 | configuration.settings["SWIFT_VERSION"] = "4.0" 61 | end 62 | 63 | target.headers_build_phase do |phase| 64 | phase.public << "KatanaElements/KatanaElements.h" 65 | end 66 | 67 | target.scheme(target.name) 68 | end 69 | 70 | # iOS Demo target 71 | demo = target do |target| 72 | target.name = "Demo" 73 | target.platform = :ios 74 | target.language = :swift 75 | target.deployment_target = 9.0 76 | target.type = :application 77 | target.linked_targets = [katana, katana_elements] 78 | 79 | target.include_files = [ 80 | "Demo/**/*.swift", 81 | "Demo/LaunchScreen.storyboard", 82 | ] 83 | 84 | target.all_configurations.each do |configuration| 85 | configuration.product_bundle_identifier = "com.bendingspoons.Demo" 86 | configuration.settings["INFOPLIST_FILE"] = "Demo/Info.plist" 87 | configuration.settings["PRODUCT_NAME"] = "Demo" 88 | configuration.settings["SWIFT_VERSION"] = "4.0" 89 | end 90 | 91 | target.scheme(target.name) 92 | end -------------------------------------------------------------------------------- /Examples/PokeAnimations/PokeAnimations/UI/Intro/KatanaElements+Intro.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KatanaElements+Intro.swift 3 | // PokeAnimations 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import KatanaElements 11 | 12 | extension View { 13 | static func pokemonBackground(for state: Intro.State.Step) -> View { 14 | 15 | let color: UIColor = { 16 | switch state { 17 | case .pokemon: 18 | return .charmender 19 | case .cute: 20 | return .eevee 21 | case .pokeball: 22 | return .pokeball 23 | case .gotcha: 24 | return .gotcha 25 | } 26 | }() 27 | 28 | return View(props: View.Props.build { 29 | $0.backgroundColor = color 30 | $0.setKey(Intro.ChildrenKeys.background) 31 | }) 32 | } 33 | } 34 | 35 | extension Image { 36 | static func pokemonImage(for state: Intro.State.Step) -> Image { 37 | 38 | let image: UIImage = { 39 | switch state { 40 | case .pokemon: 41 | return #imageLiteral(resourceName: "charmander") 42 | case .cute: 43 | return #imageLiteral(resourceName: "eevee") 44 | case .pokeball: 45 | return #imageLiteral(resourceName: "pokeball") 46 | case .gotcha: 47 | return #imageLiteral(resourceName: "gotcha") 48 | } 49 | }() 50 | 51 | let key: Intro.ChildrenKeys = { 52 | switch state { 53 | case .pokemon: 54 | return .pokemonImage 55 | case .cute: 56 | return .cuteImage 57 | case .pokeball: 58 | return .pokeballImage 59 | case .gotcha: 60 | return .gotchaImage 61 | } 62 | }() 63 | 64 | return Image(props: Image.Props.build { 65 | $0.image = image 66 | $0.backgroundColor = .clear 67 | $0.setKey(key) 68 | }) 69 | } 70 | } 71 | 72 | extension Label { 73 | static func pokemonTitle(for state: Intro.State.Step) -> Label { 74 | 75 | let content: String = { 76 | switch state { 77 | case .pokemon: 78 | return "Hi Trainer!\nThis is a Pokémon!" 79 | case .cute: 80 | return "Aren't they cute?" 81 | case .pokeball: 82 | return "You can capture Pokémons using a Pokéball!\nAim, launch and capture" 83 | case .gotcha: 84 | return "All clear?\nThere is a world out there\nYour journey stars now!\nCatch'em All" 85 | } 86 | }() 87 | 88 | let key: Intro.ChildrenKeys = { 89 | switch state { 90 | case .pokemon: 91 | return .pokemonTitle 92 | case .cute: 93 | return .cuteTitle 94 | case .pokeball: 95 | return .pokeballTitle 96 | case .gotcha: 97 | return .gotchaTitle 98 | } 99 | }() 100 | 101 | return Label(props: Label.Props.build { 102 | $0.text = .paragraphString(content) 103 | $0.textAlignment = .center 104 | $0.numberOfLines = 0 105 | $0.backgroundColor = .clear 106 | $0.setKey(key) 107 | }) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /KatanaElements/Table/NativeTableWrapperCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NativeTableWrapperCell.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import UIKit 11 | import KatanaUI 12 | import Katana 13 | 14 | class NativeTableWrapperCell: UITableViewCell { 15 | private var node: AnyNode? 16 | 17 | override public init(style: UITableViewCellStyle, reuseIdentifier: String?) { 18 | super.init(style: style, reuseIdentifier: reuseIdentifier) 19 | 20 | self.selectionStyle = .none 21 | } 22 | 23 | required init?(coder aDecoder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | func update(withparent parent: AnyNode, description: AnyNodeDescription) { 28 | var newProps = description.anyProps 29 | newProps.frame = self.bounds 30 | let newDescription = type(of: description).init(anyProps: newProps) 31 | 32 | if let node = self.node { 33 | if node.anyDescription.replaceKey == description.replaceKey { 34 | // we just need to let the node do its job 35 | node.update(with: newDescription, animation: .none, completion: nil) 36 | return 37 | } 38 | } 39 | 40 | // either we don't have a node or the description are not compatible 41 | // just create a new node 42 | for view in self.contentView.subviews { 43 | view.removeFromSuperview() 44 | } 45 | 46 | // Katana right now requires manual management of children 47 | // when we have tables/grids 48 | // let's first remove the node from the parent (if any) 49 | if let node = self.node { 50 | node.parent?.removeManagedChild(node: node) 51 | } 52 | 53 | // and then add a new node with the new description 54 | self.node = parent.addManagedChild(with: newDescription, in: self.contentView) 55 | } 56 | 57 | func didTap(atIndexPath indexPath: IndexPath) { 58 | // if we have a node, and the description is of the CellNodeDescription kind, then 59 | // we can automate the tap process 60 | if let node = self.node, let description = node.anyDescription as? AnyTableCell { 61 | let store = node.renderer?.store 62 | let dispatch = store?.dispatch ?? { fatalError("\($0) cannot be dispatched. Store not available.") } 63 | type(of: description).anyDidTap(dispatch: dispatch, props: description.anyProps, indexPath: indexPath) 64 | } 65 | } 66 | 67 | override func setHighlighted(_ highlighted: Bool, animated: Bool) { 68 | super.setHighlighted(highlighted, animated: animated) 69 | 70 | // let's see if in our subviews there is a CellNativeView, which we can use 71 | // to properly update the state 72 | // it there is such view, it is the only subview of contentview 73 | if let view = self.contentView.subviews.first as? NativeTableCell { 74 | view.setHighlighted(highlighted) 75 | } 76 | } 77 | 78 | deinit { 79 | // on cell deinit, remove the node from the tree 80 | // again, this is a current Katana limitation and we need to manage 81 | // nodes manually 82 | if let node = self.node { 83 | node.parent?.removeManagedChild(node: node) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /KatanaUI/Core/Node/PlatformNativeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlatformNativeView.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import CoreGraphics 10 | 11 | /** 12 | This protocol abstracts how `Node` instances can be rendered. We have introduced this protocol 13 | to abstract the Katana world (nodes and descriptions) from the underlying implementation of how 14 | the UI is rendered. 15 | 16 | The most obvious implementation of this protocol is `UIView` for iOS or `NSView` for mac OS. 17 | It is possible to create custom containers that renders nodes on abstract structures (e.g., for testing) 18 | or on serializable structures to store the UI representation and use it later. 19 | */ 20 | public protocol PlatformNativeView : class { 21 | 22 | /// The frame of the native view 23 | var frame: CGRect { get set } 24 | 25 | /// The alpha of the native view 26 | var alpha: CGFloat { get set } 27 | 28 | /// An unique tag value related to the native view 29 | var tagValue: Int { get set } 30 | 31 | /** 32 | Creates a new instance of the platform native view 33 | 34 | - returns: a valid instance of the platform native view 35 | */ 36 | static func make() -> Self 37 | 38 | /** 39 | Removes all the children from the container 40 | 41 | - warning: this method should be invoked in the main queue 42 | */ 43 | func removeAllChildren() 44 | 45 | /** 46 | Adds a child to the container 47 | 48 | - parameter child: a closure that returns the DrawableContainer to add to the container 49 | - returns: the container that holds the child 50 | 51 | - warning: this method should be invoked in the main queue 52 | */ 53 | @discardableResult func addChild(_ child: () -> PlatformNativeView) -> PlatformNativeView 54 | 55 | /** 56 | Adds the platform native view to a parent 57 | 58 | - parameter parent: the parent 59 | */ 60 | func addToParent(parent: PlatformNativeView) 61 | 62 | /** 63 | Updates the description 64 | 65 | - parameter updateView: a closure that takes as input the PlatformNativeView represented by the container and 66 | updates it 67 | 68 | - warning: this method should be invoked in the main queue 69 | */ 70 | func update(with updateView: (PlatformNativeView)->()) 71 | 72 | /// Returns the children of the container 73 | func children () -> [PlatformNativeView] 74 | 75 | /** 76 | Moves to the front a child 77 | 78 | - parameter child: the child to move to the front 79 | */ 80 | func bringChildToFront(_ child: PlatformNativeView) 81 | 82 | /** 83 | Removes a child 84 | 85 | - parameter child: the child to remove 86 | */ 87 | func removeChild(_ child: PlatformNativeView) 88 | 89 | /** 90 | Animates UI changes performed in a block with the animation specified by the AnimationType 91 | - parameter type: the type of the animation 92 | - parameter block: a block that contains the updates to the UI to animate 93 | - parameter completion: a block that is called when the animation completes 94 | */ 95 | static func animate(type: AnimationType, _ block: @escaping ()->(), completion: (() -> ())?) 96 | } 97 | -------------------------------------------------------------------------------- /KatanaUI/Core/Node/AnyNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyNode.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | /// The completion block for the update operation 12 | public typealias NodeUpdateCompletion = () -> () 13 | 14 | /// Type Erasure protocol for the `Node` class. 15 | public protocol AnyNode: class { 16 | 17 | /// Type erasure for the `NodeDescription` that the node holds 18 | var anyDescription: AnyNodeDescription { get } 19 | 20 | /// Type erasure for the `NodeDescriptionState` that the node holds 21 | var anyState: Any { get } 22 | 23 | /// Children nodes of the node 24 | var children: [AnyNode]! { get } 25 | 26 | /// Array of managed children. See `Node` description for more information about managed children 27 | var managedChildren: [AnyNode] { get } 28 | 29 | /// The parent of the node 30 | var parent: AnyNode? { get } 31 | 32 | /// The container of the node 33 | var container: PlatformNativeView? { get } 34 | 35 | /** 36 | The renderer of the node. This is a computed variable that traverses the tree up to the root node and returns `root.renderer` 37 | */ 38 | var renderer: Renderer? { get } 39 | 40 | /** 41 | Updates the node with a new description. Invoking this method will cause an update of the piece of the UI managed by the node 42 | 43 | - parameter description: the new description to use to describe the UI 44 | - throws: this method throw an exception if the given description is not compatible with the node 45 | */ 46 | func update(with description: AnyNodeDescription) 47 | 48 | /** 49 | Updates the node with a new description. Invoking this method will cause an update of the piece of the UI managed by the node 50 | The transition from the old description to the new one will be animated 51 | 52 | - parameter description: the new description to use to describe the UI 53 | - parameter parentAnimation: the animation to use to transition from the old description to the new one 54 | - parameter completion: a completion block to invoke when the update is completed 55 | 56 | - throws: this method throw an exception if the given description is not compatible with the node 57 | */ 58 | func update(with description: AnyNodeDescription, animation: AnimationContainer, completion: NodeUpdateCompletion?) 59 | 60 | /** 61 | Adds a managed child to the node. For more information about managed children see the `Node` class 62 | 63 | - parameter description: the description that will characterize the node that will be added 64 | - parameter container: the container in which the new node will be drawn 65 | 66 | - returns: the node that has been created. The node will have the current node as parent 67 | */ 68 | func addManagedChild(with description: AnyNodeDescription, in container: PlatformNativeView) -> AnyNode 69 | 70 | /** 71 | Removes a managed child from the node. For more information about managed children see the `Node` class 72 | 73 | - parameter node: the node to remove 74 | */ 75 | func removeManagedChild(node: AnyNode) 76 | 77 | /// Forces the reload of the node regardless the fact that props and state are changed 78 | func forceReload() 79 | } 80 | -------------------------------------------------------------------------------- /KatanaTests/Animations/ChildrenAnimationsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChildrenAnimationsTests.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import XCTest 11 | import UIKit 12 | import Katana 13 | @testable import KatanaUI 14 | 15 | class ChildrenAnimationsTests: XCTestCase { 16 | func testShouldAnimateEmpty() { 17 | let empty = ChildrenAnimations() 18 | XCTAssertEqual(empty.shouldAnimate, false) 19 | } 20 | 21 | func testNoAnimation() { 22 | enum TestKey { case testKey } 23 | var container = ChildrenAnimations() 24 | container[.testKey] = Animation(type: .none) 25 | XCTAssertEqual(container.shouldAnimate, false) 26 | } 27 | 28 | func testAnimation() { 29 | enum TestKey { case testKey } 30 | var container = ChildrenAnimations() 31 | container[.testKey] = Animation(type: .linear(duration: 0.3)) 32 | XCTAssertEqual(container.shouldAnimate, true) 33 | } 34 | 35 | func testAllChildren() { 36 | enum TestKey { case testKey } 37 | var container = ChildrenAnimations() 38 | container.allChildren = Animation(type: .linear(duration: 0.3)) 39 | 40 | let animation = container[.testKey] 41 | 42 | if case let .linear(value) = animation.type { 43 | XCTAssertEqual(value, 0.3) 44 | 45 | } else { 46 | XCTFail() 47 | } 48 | } 49 | 50 | func testShouldAnimateAllAnimation() { 51 | enum TestKey { case testKey } 52 | var container = ChildrenAnimations() 53 | container.allChildren = Animation(type: .linear(duration: 0.3)) 54 | XCTAssertEqual(container.shouldAnimate, true) 55 | } 56 | 57 | func testShouldNotAnimateAllAnimation() { 58 | enum TestKey { case testKey } 59 | var container = ChildrenAnimations() 60 | container.allChildren = Animation(type: .none) 61 | XCTAssertEqual(container.shouldAnimate, false) 62 | } 63 | 64 | func testOverwriteAllChildren() { 65 | enum TestKey { case testKey, anotherKey } 66 | var container = ChildrenAnimations() 67 | 68 | container[.anotherKey] = Animation(type: .linear(duration: 0.4)) 69 | container.allChildren = Animation(type: .linear(duration: 0.3)) 70 | 71 | let animation = container[.testKey] 72 | let anotherAnimation = container[.anotherKey] 73 | 74 | if case let .linear(value) = animation.type { 75 | XCTAssertEqual(value, 0.3) 76 | 77 | } else { 78 | XCTFail() 79 | } 80 | 81 | if case let .linear(value) = anotherAnimation.type { 82 | XCTAssertEqual(value, 0.4) 83 | 84 | } else { 85 | XCTFail() 86 | } 87 | } 88 | 89 | func testMultipleSet() { 90 | enum TestKey { case testKey, anotherKey } 91 | var container = ChildrenAnimations() 92 | 93 | container[[.anotherKey, .testKey]] = Animation(type: .linear(duration: 0.4)) 94 | 95 | let animation = container[.testKey] 96 | let anotherAnimation = container[.anotherKey] 97 | 98 | if case let .linear(value) = animation.type { 99 | XCTAssertEqual(value, 0.4) 100 | 101 | } else { 102 | XCTFail() 103 | } 104 | 105 | if case let .linear(value) = anotherAnimation.type { 106 | XCTAssertEqual(value, 0.4) 107 | 108 | } else { 109 | XCTFail() 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /KatanaUI/Core/StoreConnection/ConnectedNodeDescription.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConnectedNodeDescription.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import Katana 11 | 12 | /// Type Erasure for `ConnectedNodeDescription` 13 | public protocol AnyConnectedNodeDescription { 14 | /** 15 | Connects the storage state to the description. 16 | 17 | - seeAlso: `ConnectedNodeDescription`, `connect(props:to:)` method 18 | */ 19 | static func anyConnect(parentProps: Any, storeState: Any) -> Any 20 | } 21 | 22 | /** 23 | In applications developed with Katana, the application information is stored in a central Store. 24 | There are cases where you want to take pieces of information from the central store and use them in your UI. 25 | `ConnectedNodeDescription` is the protocol that is used to implement this behaviour. 26 | 27 | By implementing this protocol in a description you get two behaviours: store information merging and automatic UI update. 28 | 29 | ### Merge description's props and Store's state 30 | Every time there is an UI update (e.g., because either props or state are changed), Katana allows you to 31 | inject in the props information that are taken from the central Store. You can use the `connect` 32 | method to implement this behaviour. 33 | 34 | ### Automatic UI update 35 | Every time the Store's state changes, you may want to update the UI. When you adopt the `ConnectedNodeDescription` 36 | protocol, Katana will trigger an UI update to all the nodes that are related to the description. 37 | The system will search for all the nodes that have a description that implements this protocol. It will then 38 | calculate the new props, by invoking `connect`. If the properties are changed, then the UI update is triggered. 39 | In this way we are able to effectively trigger UI changes only where and when needed. 40 | 41 | - seeAlso: `Store` 42 | */ 43 | public protocol ConnectedNodeDescription: AnyConnectedNodeDescription, NodeDescription { 44 | 45 | /// The State used in the application 46 | associatedtype StoreState: State 47 | 48 | /** 49 | This method is used to update the properties with pieces of information taken from the 50 | central Store state. 51 | 52 | The idea of this method is that it takes the properties defined by the parent in the 53 | `childrenDescriptions` method and the store state. 54 | The implementation should update the props with all the information that are needed to properly 55 | render the UI. 56 | 57 | - parameter props: the props defined by the parent 58 | - parameter storeState: the state of the Store 59 | */ 60 | static func connect(props: inout PropsType, to storeState: StoreState) 61 | } 62 | 63 | public extension ConnectedNodeDescription { 64 | /** 65 | Default implementation of `anyConnect`. It invokes `connect(props:to:)` by casting the parameters 66 | to the proper types. 67 | 68 | - seeAlso: `AnyConnectedNodeDescription` 69 | */ 70 | static func anyConnect(parentProps: Any, storeState: Any) -> Any { 71 | 72 | guard let parentProps = parentProps as? PropsType, let s = storeState as? StoreState else { 73 | fatalError("invalid signature of the connect function of \(type(of: self))") 74 | } 75 | 76 | var parentPropsCopy = parentProps 77 | self.connect(props: &parentPropsCopy, to: s) 78 | return parentPropsCopy 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /KatanaUI/Core/Animations/ChildrenAnimations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChildrenAnimations.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | /** 12 | This struct is used as container to define the animations for the children 13 | */ 14 | public struct ChildrenAnimations { 15 | /// It indicates whether we should perform a 4 step animation or not 16 | var shouldAnimate = false 17 | 18 | /// The animations of the children 19 | var animations = [String: Animation]() 20 | 21 | /// A default that is used for all the children without a specific animation 22 | public var allChildren: Animation = .none { 23 | didSet { 24 | if case .none = self.allChildren.type { 25 | return 26 | } 27 | 28 | self.shouldAnimate = true 29 | } 30 | } 31 | 32 | /** 33 | Gets the `Animation` value relative to a specific key 34 | 35 | - parameter key: the key of the children to retrieve 36 | - returns: the `Animation` value related to the key 37 | 38 | If the key has not been defined, the `allChildren` value is returned 39 | */ 40 | public subscript(key: Key) -> Animation { 41 | get { 42 | return self["\(key)"] 43 | } 44 | 45 | set(newValue) { 46 | if case .none = newValue.type { 47 | return 48 | } 49 | 50 | self.shouldAnimate = true 51 | self.animations["\(key)"] = newValue 52 | } 53 | } 54 | 55 | /** 56 | - note: This subscript should be used only to set values. 57 | 58 | It is an helper to specify the same animation for multiple keys 59 | */ 60 | public subscript(key: [Key]) -> Animation { 61 | get { 62 | fatalError("This subscript should not be used as a getter") 63 | } 64 | 65 | set(newValue) { 66 | for value in key { 67 | self[value] = newValue 68 | } 69 | } 70 | } 71 | 72 | /** 73 | Gets the `Animation` value relative to a specific key 74 | 75 | - parameter key: the key of the children to retrieve 76 | - returns: the `Animation` value related to the key 77 | 78 | If the key has not been defined, the `allChildren` value is returned 79 | */ 80 | subscript(key: String) -> Animation { 81 | return self.animations[key] ?? self.allChildren 82 | } 83 | } 84 | 85 | /// Type Erasure for ChildrenAnimations 86 | protocol AnyChildrenAnimations { 87 | /// It indicates whether we should perform a 4 step animation or not 88 | var shouldAnimate: Bool { get } 89 | 90 | /** 91 | Gets the `Animation` value relative to a description 92 | 93 | - parameter description: the description 94 | - returns: the `Animation` value related to the description 95 | 96 | If the children doesn't have a specific value, the `allChildren` value is returned 97 | */ 98 | subscript(description: AnyNodeDescription) -> Animation { get } 99 | } 100 | 101 | /// Implementation of AnyChildrenAnimations 102 | extension ChildrenAnimations: AnyChildrenAnimations { 103 | /** 104 | Implementation of the AnyChildrenAnimations protocol. 105 | 106 | - seeAlso: `AnyChildrenAnimations` 107 | */ 108 | subscript(description: AnyNodeDescription) -> Animation { 109 | if let key = description.anyProps.key { 110 | return self[key] 111 | } 112 | 113 | return self.allChildren 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Demo/UI/CounterScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CounterScreen.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Katana 10 | import KatanaElements 11 | import KatanaUI 12 | 13 | extension CounterScreen { 14 | enum Keys { 15 | case label 16 | case incrementButton 17 | case decrementButton 18 | } 19 | 20 | struct Props: NodeDescriptionProps, Buildable { 21 | var alpha: CGFloat = 1.0 22 | var frame: CGRect = .zero 23 | var key: String? 24 | 25 | var count: Int = 0 26 | } 27 | } 28 | 29 | struct CounterScreen: ConnectedNodeDescription, PlasticNodeDescription, PlasticReferenceSizeable { 30 | typealias StateType = EmptyState 31 | typealias PropsType = Props 32 | typealias NativeView = UIView 33 | 34 | var props: PropsType 35 | 36 | static var referenceSize = CGSize(width: 640, height: 960) 37 | 38 | public static func childrenDescriptions(props: PropsType, 39 | state: StateType, 40 | update: @escaping (StateType) -> (), 41 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 42 | 43 | return [ 44 | Label(props: Label.Props.build({ 45 | $0.setKey(Keys.label) 46 | $0.textAlignment = .center 47 | $0.backgroundColor = .mediumAquamarine 48 | $0.text = NSAttributedString(string: "Count: \(props.count)") 49 | })), 50 | Button(props: Button.Props.build({ 51 | $0.setKey(Keys.decrementButton) 52 | $0.titles[.normal] = "Decrement" 53 | $0.backgroundColor = .dogwoodRose 54 | $0.titleColors = [.highlighted: .jet] 55 | 56 | $0.touchHandlers = [ 57 | .touchUpInside: { 58 | dispatch(DecrementCounter()) 59 | } 60 | ] 61 | })), 62 | Button(props: Button.Props.build({ 63 | $0.setKey(Keys.incrementButton) 64 | $0.titles[.normal] = "Increment" 65 | $0.backgroundColor = .japaneseIndigo 66 | $0.titleColors = [.highlighted: .jet] 67 | 68 | $0.touchHandlers = [ 69 | .touchUpInside: { 70 | dispatch(IncrementCounter()) 71 | } 72 | ] 73 | })) 74 | ] 75 | } 76 | 77 | public static func connect(props: inout PropsType, to storeState: CounterState) { 78 | props.count = storeState.counter 79 | } 80 | 81 | public static func layout(views: ViewsContainer, props: PropsType, state: StateType) { 82 | let rootView = views.nativeView 83 | 84 | let label = views[.label]! 85 | let decrementButton = views[.decrementButton]! 86 | let incrementButton = views[.incrementButton]! 87 | label.asHeader(rootView) 88 | [label, decrementButton].fill(top: rootView.top, bottom: rootView.bottom) 89 | incrementButton.top = decrementButton.top 90 | incrementButton.bottom = decrementButton.bottom 91 | [decrementButton, incrementButton].fill(left: rootView.left, right: rootView.right) 92 | } 93 | } 94 | 95 | extension UIColor { 96 | static var mediumAquamarine: UIColor { 97 | return UIColor(red: 89.0/255.0, green: 201.0/255.0, blue: 165.0/255.0, alpha: 1.0) 98 | } 99 | 100 | static var dogwoodRose: UIColor { 101 | return UIColor(red: 216.0/255.0, green: 30.0/255.0, blue: 91.0/255.0, alpha: 1.0) 102 | } 103 | 104 | static var japaneseIndigo: UIColor { 105 | return UIColor(red: 35.0/255.0, green: 57.0/255.0, blue: 91.0/255.0, alpha: 1.0) 106 | } 107 | 108 | static var jet: UIColor { 109 | return UIColor(red: 51.0/255.0, green: 49.0/255.0, blue: 46.0/255.0, alpha: 1.0) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /KatanaUI.xcodeproj/xcshareddata/xcschemes/KatanaElements.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 33 | 34 | 40 | 41 | 42 | 43 | 44 | 45 | 56 | 58 | 64 | 65 | 66 | 67 | 68 | 69 | 75 | 77 | 83 | 84 | 85 | 86 | 88 | 89 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /KatanaElements/Table/NativeTable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NativeTable.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import UIKit 11 | import KatanaUI 12 | import Katana 13 | 14 | private let cellIdentifier = "KATANA_CELLIDENTIFIER" 15 | 16 | public class NativeTable: UITableView { 17 | private(set) weak var parent: AnyNode? 18 | private(set) var katanaDelegate: TableDelegate? 19 | 20 | override public init(frame: CGRect, style: UITableViewStyle) { 21 | super.init(frame: frame, style: style) 22 | 23 | self.register(NativeTableWrapperCell.self, forCellReuseIdentifier: cellIdentifier) 24 | self.tableFooterView = UIView() 25 | self.separatorStyle = .none 26 | 27 | self.delegate = self 28 | self.dataSource = self 29 | } 30 | 31 | public required init?(coder aDecoder: NSCoder) { 32 | fatalError("init(coder:) has not been implemented") 33 | } 34 | 35 | func update(withparent parent: AnyNode, delegate: TableDelegate) { 36 | self.parent = parent 37 | 38 | if let currentDelegate = self.katanaDelegate, currentDelegate.isEqual(to: delegate) { 39 | return 40 | } 41 | 42 | self.katanaDelegate = delegate 43 | self.reloadData() 44 | } 45 | } 46 | 47 | extension NativeTable: UITableViewDataSource { 48 | public func numberOfSections(in tableView: UITableView) -> Int { 49 | if let delegate = self.katanaDelegate { 50 | return delegate.numberOfSections() 51 | } 52 | 53 | return 0 54 | } 55 | 56 | public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 57 | if let delegate = self.katanaDelegate { 58 | return delegate.numberOfRows(forSection: section) 59 | } 60 | 61 | return 0 62 | } 63 | 64 | @objc(tableView:cellForRowAtIndexPath:) 65 | public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 66 | let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as! NativeTableWrapperCell 67 | 68 | if let parent = self.parent, let delegate = self.katanaDelegate { 69 | let description = delegate.cellDescription(forRowAt: indexPath) 70 | cell.update(withparent: parent, description: description) 71 | } 72 | 73 | return cell 74 | } 75 | 76 | @objc(tableView:heightForRowAtIndexPath:) 77 | public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 78 | 79 | if let delegate = self.katanaDelegate, let node = self.parent { 80 | let value = delegate.height(forRowAt: indexPath) 81 | return value.scale(by: node.plasticMultiplier) 82 | } 83 | 84 | return 0 85 | } 86 | } 87 | 88 | extension NativeTable: UITableViewDelegate { 89 | public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 90 | let cell = tableView.cellForRow(at: indexPath) as! NativeTableWrapperCell 91 | cell.didTap(atIndexPath: indexPath) 92 | } 93 | } 94 | 95 | extension NativeTable: NativeViewWithRef { 96 | public typealias RefType = TableRef 97 | 98 | public var ref: RefType { 99 | return TableRef(nativeView: self) 100 | } 101 | } 102 | 103 | public struct TableRef: NativeViewRef { 104 | public typealias NativeViewType = NativeTable 105 | 106 | private weak var nativeView: NativeViewType? 107 | 108 | public var isValid: Bool { 109 | return self.nativeView != nil 110 | } 111 | 112 | public init(nativeView: NativeViewType) { 113 | weak var s: NativeViewType? = nativeView 114 | self.nativeView = s 115 | } 116 | 117 | public func scroll(at indexPath: IndexPath, at position: UITableViewScrollPosition, animated: Bool) { 118 | self.nativeView?.scrollToRow(at: indexPath, at: position, animated: animated) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /KatanaElements/Label.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import UIKit 10 | import KatanaUI 11 | import Katana 12 | 13 | public extension Label { 14 | public struct Props: NodeDescriptionProps, Buildable { 15 | public var frame = CGRect.zero 16 | public var key: String? 17 | public var alpha: CGFloat = 1.0 18 | 19 | public var backgroundColor = UIColor.white 20 | public var cornerRadius: Value = .zero 21 | public var borderWidth: Value = .zero 22 | public var borderColor = UIColor.clear 23 | public var clipsToBounds = true 24 | public var isUserInteractionEnabled = false 25 | public var text: NSAttributedString? 26 | public var textAlignment: NSTextAlignment = .left 27 | public var lineBreakMode: NSLineBreakMode = .byClipping 28 | public var numberOfLines: Int = 1 29 | public var adjustsFontSizeToFitWidth: Bool = true 30 | public var allowsDefaultTighteningForTruncation: Bool = true 31 | public var minimumScaleFactor: CGFloat = 0.10 32 | 33 | public static func == (lhs: Props, rhs: Props) -> Bool { 34 | return 35 | lhs.frame == rhs.frame && 36 | lhs.key == rhs.key && 37 | lhs.alpha == rhs.alpha && 38 | lhs.backgroundColor == rhs.backgroundColor && 39 | lhs.cornerRadius == rhs.cornerRadius && 40 | lhs.borderWidth == rhs.borderWidth && 41 | lhs.borderColor == rhs.borderColor && 42 | lhs.clipsToBounds == rhs.clipsToBounds && 43 | lhs.isUserInteractionEnabled == rhs.isUserInteractionEnabled && 44 | lhs.text == rhs.text && 45 | lhs.textAlignment == rhs.textAlignment && 46 | lhs.lineBreakMode == rhs.lineBreakMode && 47 | lhs.numberOfLines == rhs.numberOfLines && 48 | lhs.adjustsFontSizeToFitWidth == rhs.adjustsFontSizeToFitWidth && 49 | lhs.allowsDefaultTighteningForTruncation == rhs.allowsDefaultTighteningForTruncation && 50 | lhs.minimumScaleFactor == rhs.minimumScaleFactor 51 | } 52 | 53 | public init() {} 54 | } 55 | } 56 | 57 | public struct Label: NodeDescription { 58 | public typealias NativeView = UILabel 59 | 60 | public var props: Props 61 | 62 | public static func applyPropsToNativeView(props: Props, 63 | state: EmptyState, 64 | view: UILabel, 65 | update: @escaping (EmptyState)->(), 66 | node: AnyNode) { 67 | 68 | view.frame = props.frame 69 | view.alpha = props.alpha 70 | view.backgroundColor = props.backgroundColor 71 | view.layer.cornerRadius = props.cornerRadius.scale(by: node.plasticMultiplier) 72 | view.layer.borderWidth = props.borderWidth.scale(by: node.plasticMultiplier) 73 | view.layer.borderColor = props.borderColor.cgColor 74 | view.clipsToBounds = props.clipsToBounds 75 | view.isUserInteractionEnabled = props.isUserInteractionEnabled 76 | view.attributedText = props.text 77 | view.textAlignment = props.textAlignment 78 | view.lineBreakMode = props.lineBreakMode 79 | view.numberOfLines = props.numberOfLines 80 | view.adjustsFontSizeToFitWidth = props.adjustsFontSizeToFitWidth 81 | view.minimumScaleFactor = props.minimumScaleFactor 82 | 83 | if #available(iOS 9.0, *) { 84 | view.allowsDefaultTighteningForTruncation = props.allowsDefaultTighteningForTruncation 85 | } 86 | } 87 | 88 | public static func childrenDescriptions(props: Props, 89 | state: EmptyState, 90 | update: @escaping (EmptyState)->(), 91 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 92 | return [] 93 | } 94 | 95 | public init(props: Props) { 96 | self.props = props 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Examples/PokeAnimations/README.md: -------------------------------------------------------------------------------- 1 | #PokeAnimations 2 | This example shows a potential onboard for a mobile game. We loop between slides and we animate content that appear or disappear in each slide. 3 | Here is the final result. 4 | 5 | ![](PokeAnimationsExample.gif) 6 | 7 | ###What is showcased here: 8 | 9 | The main purpose of this example is to show the Katana animation system. Look at the `updateChildrenAnimations` to understand how you can animate elements of your UI. 10 | 11 | ## How animations work 12 | ### Show me the code 13 | Before digging into the theory of the animations, let's take a look to how you can use them. 14 | The only thing we need to know here is that a transition from an UI state A to another UI state B can involve insertion or removal of UI elements. We refer to the formers as `leave elements` and to the latters as `entry elements`. This is because elements leave and entry in the UI. 15 | 16 | This is the method you need to implement to define the animation logic 17 | ```swift 18 | static func updateChildrenAnimations(container: inout ChildrenAnimations, 19 | currentProps: PropsType, 20 | nextProps: PropsType, 21 | currentState: StateType, 22 | nextState: StateType) { 23 | // your logic here 24 | } 25 | ``` 26 | Based on the current props and state and to the next ones, you can define which animations you want. If you don't update the `container`, there won't be any animation. 27 | 28 | You can assign an animation to each element in the following way 29 | ```swift 30 | container[key] = Animation( 31 | type: type, 32 | entryTransformers: entryTransformers, 33 | entryTransformers: leaveTransformers 34 | ) 35 | ``` 36 | The `type` is the animation type (linear or spring) and you can also define parameters such as the duration. 37 | 38 | What about `entryTransformers` and `leaveTransformers`? 39 | The `entryTransformers` are a list of functions that are applied to the elements that are about to enter in the UI, just before we perform the animation. The result is that we will see an animation from the UI state defined by the props transformed, to the final state (which is represented by the props you returned in the `childrenDescriptions` method). 40 | The `leaveTransformers` is the same, but for elements that are about to leave the UI. 41 | 42 | You can use built in transformers (like we do in the example) or create your own. A transformer is simply a function with the following signature: 43 | ```swift 44 | (_ props: AnyNodeDescriptionProps) -> AnyNodeDescriptionProps 45 | ``` 46 | 47 | ### Animation Theory 48 | Let's say we want to animate from a UI state A (current state) to a UI state B (a new state). 49 | We move to B either because the properties are changed or the state is changed. 50 | 51 | UIKit provides powerful methods to animate changes in the UI. In particular, we can leverage the `UIView.animate`. 52 | But what happens when B contains new elements? Or it doesn't contains elements that were in A? Katana will create or destroy these pieces of UI, but this can't be managed by UIKit. 53 | 54 | In order to address this problem we introduced a 4 step animation: 55 | - The first step is the current UI state: A 56 | - We render an intermediate step. This step is basically identical to A but we also create all the elements there are in B but not in A. We then apply to them the proper `entryTransformers` if available. The transition from A to this first intermediate step is not animated 57 | - We render a second intermediate step. This is basically identical to B but we keep al the elements that were in B even if they are no more present in B. To each element that is in A but not in B, we apply the `leaveTransformers` if available. The transition between the two intermediate steps is animated using the animations specified in the `updateChildrenAnimations` method 58 | - We render B. The transition is not animated 59 | 60 | By using this approach we are able to animate creation and deletion of UI elements gracefully. 61 | -------------------------------------------------------------------------------- /KatanaUI/Plastic/Anchor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Anchor.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | import CoreGraphics 9 | 10 | /** 11 | An abstract representation of a `PlasticView` layout property. 12 | */ 13 | public struct Anchor: Equatable { 14 | 15 | /// This enum represents the possible kinds of anchor 16 | public enum Kind { 17 | /// the left edge of the view the anchor pertains to 18 | case left 19 | /// the right edge of the view the anchor pertains to 20 | case right 21 | /// the horizontal center of the view the anchor pertains to 22 | case centerX 23 | /// the top edge of the view the anchor pertains to 24 | case top 25 | /// the bottom edge of the view the anchor pertains to 26 | case bottom 27 | /// the vertical center of the view the anchor pertains to 28 | case centerY 29 | } 30 | 31 | /// the kind of the anchor 32 | let kind: Kind 33 | 34 | /// the `PlasticView` to which the anchor is associated to 35 | let view: PlasticView 36 | 37 | /// the offset at which this anchor will be set, with respect to the anchor it will be assigned to 38 | let offset: Value 39 | 40 | /** 41 | Creates an anchor with a given type, related to a specific `PlasticView` 42 | 43 | - parameter kind: the kind of the anchor 44 | - parameter view: the view the anchor pertains to 45 | - parameter offset: the offset at which this anchor will be set, with respect to the anchor it will be assigned to 46 | */ 47 | init(kind: Kind, view: PlasticView, offset: Value = .zero) { 48 | self.kind = kind 49 | self.view = view 50 | self.offset = offset 51 | } 52 | 53 | /** 54 | As the `Anchor` instance symbolizes a line in a given view, this method will return that line's horizontal or vertical 55 | coordinate in the node description native view's coordinate system 56 | */ 57 | var coordinate: CGFloat { 58 | let absoluteOrigin = self.view.absoluteOrigin 59 | let size = self.view.frame 60 | let coord: CGFloat 61 | 62 | switch self.kind { 63 | case .left: 64 | coord = absoluteOrigin.x 65 | 66 | case .right: 67 | coord = absoluteOrigin.x + size.width 68 | 69 | case .centerX: 70 | coord = absoluteOrigin.x + size.width / 2.0 71 | 72 | case .top: 73 | coord = absoluteOrigin.y 74 | 75 | case .bottom: 76 | coord = absoluteOrigin.y + size.height 77 | 78 | case .centerY: 79 | coord = absoluteOrigin.y + size.height / 2.0 80 | 81 | } 82 | 83 | return coord + view.scaleValue(offset) 84 | } 85 | 86 | /** 87 | Implementation of the Equatable protocol 88 | 89 | - parameter lhs: the first anchor 90 | - parameter rhs: the second anchor 91 | - returns: true if the two anchors are equals, false otherwise. Two anchors are considered 92 | equal if the are related to the same view and to they also have the same kind 93 | */ 94 | public static func == (lhs: Anchor, rhs: Anchor) -> Bool { 95 | return lhs.kind == rhs.kind && lhs.view === rhs.view 96 | } 97 | 98 | /** 99 | Create an anchor equal to `lhs`, but with an offset equal to `lhs.offset + rhs` 100 | */ 101 | public static func + (lhs: Anchor, rhs: Value) -> Anchor { 102 | return Anchor(kind: lhs.kind, view: lhs.view, offset: lhs.offset + rhs) 103 | } 104 | 105 | /** 106 | Create an anchor equal to `lhs`, but with an offset equal to `lhs.offset - rhs` 107 | */ 108 | public static func - (lhs: Anchor, rhs: Value) -> Anchor { 109 | return Anchor(kind: lhs.kind, view: lhs.view, offset: lhs.offset + -rhs) 110 | } 111 | 112 | /** 113 | Create an anchor equal to `lhs`, but with an offset equal to `lhs.offset + Value.scalable(rhs)` 114 | */ 115 | public static func + (lhs: Anchor, rhs: CGFloat) -> Anchor { 116 | return lhs + .scalable(rhs) 117 | } 118 | 119 | /** 120 | Create an anchor equal to `lhs`, but with an offset equal to `lhs.offset - Value.scalable(rhs)` 121 | */ 122 | public static func - (lhs: Anchor, rhs: CGFloat) -> Anchor { 123 | return lhs - .scalable(rhs) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/State/MinesweeperState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinesweeperState.swift 3 | // Minesweeper 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Katana 10 | 11 | struct MinesweeperState: State { 12 | var gameOver: Bool 13 | let rows: Int 14 | let cols: Int 15 | var mines: [Bool] 16 | var disclosed: [Bool] 17 | 18 | init() { 19 | self.init(difficulty: .hard) 20 | } 21 | 22 | init(difficulty: Difficulty) { 23 | switch difficulty { 24 | case .easy: 25 | self.init(cols: 9, rows: 9, mines: 10) 26 | case .medium: 27 | self.init(cols: 15, rows: 16, mines: 35) 28 | case .hard: 29 | self.init(cols: 15, rows: 30, mines: 90) 30 | } 31 | } 32 | 33 | private init(cols: Int, rows: Int, mines: Int) { 34 | self.gameOver = false 35 | self.rows = rows 36 | self.cols = cols 37 | self.mines = Array(repeating: false, count: rows * cols) 38 | self.disclosed = Array(repeating: false, count: rows * cols) 39 | self.poseMines(numberOfMines: mines) 40 | } 41 | 42 | } 43 | 44 | // MARK: - Mines Operations 45 | extension MinesweeperState { 46 | fileprivate mutating func poseMines(numberOfMines: Int) { 47 | var i = 0 48 | while i < numberOfMines { 49 | let r = Int.random(max: rows) 50 | let c = Int.random(max: cols) 51 | if( self[c, r] != true) { 52 | i += 1 53 | self[c, r] = true 54 | } 55 | } 56 | } 57 | 58 | func minesNearbyCellAt(col: Int, row: Int) -> Int { 59 | var mines = 0 60 | for index in self.nearbyCellsIndicesAt(col: col, row: row) { 61 | if(self[index.0, index.1]) { 62 | mines += 1 63 | } 64 | } 65 | return mines 66 | } 67 | 68 | fileprivate func nearbyCellsIndicesAt(col: Int, row: Int) -> [(Int, Int)] { 69 | var indices: [(Int, Int)] = [] 70 | let startCol = col - 1 71 | let endCol = col + 1 72 | let startRow = row - 1 73 | let endRow = row + 1 74 | for currentCol in startCol...endCol { 75 | for currentRow in startRow...endRow { 76 | 77 | guard currentCol >= 0 && currentCol < cols else { 78 | continue 79 | } 80 | 81 | guard currentRow >= 0 && currentRow < rows else { 82 | continue 83 | } 84 | 85 | if currentRow == row && currentCol == col { 86 | continue 87 | } 88 | 89 | indices.append((currentCol, currentRow)) 90 | } 91 | } 92 | return indices 93 | } 94 | 95 | subscript(col: Int, row: Int) -> Bool { 96 | get { return mines[cols * row + col] } 97 | set { mines[cols*row+col] = newValue } 98 | } 99 | } 100 | 101 | // MARK: - Disclosure 102 | extension MinesweeperState { 103 | mutating func disclose(col: Int, row: Int) { 104 | guard !gameOver else { return } 105 | var cellsToDisclose = [(col, row)] 106 | 107 | while !cellsToDisclose.isEmpty { 108 | let index = cellsToDisclose.removeFirst() 109 | if(!self.isDisclosed(col: index.0, row: index.1 )) { 110 | self.discloseCellAt(col: index.0, row: index.1) 111 | if(self[index.0, index.1]) { 112 | self.gameOver = true 113 | return 114 | } 115 | if(self.minesNearbyCellAt(col: index.0, row: index.1) == 0) { 116 | cellsToDisclose.append(contentsOf: self.nearbyCellsIndicesAt(col: index.0, row: index.1)) 117 | } 118 | 119 | } 120 | } 121 | self.discloseCellAt(col: col, row: row) 122 | } 123 | 124 | fileprivate mutating func discloseCellAt(col: Int, row: Int) { 125 | disclosed[cols*row+col] = true 126 | } 127 | 128 | func isDisclosed(col: Int, row: Int) -> Bool { 129 | return disclosed[cols*row+col] 130 | } 131 | } 132 | 133 | // MARK: - Difficulty 134 | extension MinesweeperState { 135 | enum Difficulty { 136 | case easy, medium, hard 137 | } 138 | } 139 | 140 | // MARK: - Equality 141 | extension MinesweeperState { 142 | 143 | static func == (lhs: MinesweeperState, rhs: MinesweeperState) -> Bool { 144 | return lhs.mines == rhs.mines && lhs.disclosed == rhs.disclosed 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /KatanaUI/Plastic/Size.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Size.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import CoreGraphics 10 | 11 | /// `Size` is the scalable counterpart of `CGSize` 12 | public struct Size: Equatable { 13 | 14 | /// The width value 15 | public let width: Value 16 | 17 | /// The height value 18 | public let height: Value 19 | 20 | /// an instance of `Size` where both the width and the height are zero 21 | public static let zero: Size = .scalable(0, 0) 22 | 23 | /** 24 | Creates an instance of `Size` where both widht and height are fixed 25 | 26 | - parameter width: the value of the width 27 | - parameter height: the value of the height 28 | 29 | - returns: an instance of `Size` where both width and height are fixed 30 | */ 31 | public static func fixed(_ width: CGFloat, _ height: CGFloat) -> Size { 32 | return Size(width: .fixed(width), height: .fixed(height)) 33 | } 34 | 35 | /** 36 | Creates an instance of `Size` where both widht and height are scalable 37 | 38 | - parameter width: the value of the width 39 | - parameter height: the value of the height 40 | 41 | - returns: an instance of `Size` where both width and height are scalable 42 | */ 43 | public static func scalable(_ width: CGFloat, _ height: CGFloat) -> Size { 44 | return Size(width: .scalable(width), height: .scalable(height)) 45 | } 46 | 47 | /** 48 | Creates an instance of `Size` with the given value 49 | 50 | - parameter width: the width to use 51 | - parameter height: the height to use 52 | 53 | - returns: an instance of `Size` with the given value 54 | */ 55 | public init(width: Value, height: Value) { 56 | self.width = width 57 | self.height = height 58 | } 59 | 60 | /** 61 | Scales the size using a multiplier 62 | 63 | - parameter multiplier: the multiplier to use to scale the size 64 | - returns: an instance of `CGSize` that is the result of the scaling process 65 | */ 66 | public func scale(by multiplier: CGFloat) -> CGSize { 67 | return CGSize( 68 | width: self.width.scale(by: multiplier), 69 | height: self.height.scale(by: multiplier) 70 | ) 71 | } 72 | 73 | /** 74 | Implements the multiplication for the `Size` instances 75 | 76 | - parameter lhs: the `Size` instance 77 | - parameter rhs: the the mutliplier to apply 78 | - returns: an instance of `Size` where the values are multiplied by `rhs` 79 | 80 | - warning: this method is different from `scale(by:)` since it scales both 81 | scalable and fixed values, whereas `scale(by:)` scales only the scalable 82 | values 83 | */ 84 | public static func * (lhs: Size, rhs: CGFloat) -> Size { 85 | return Size(width: lhs.width * rhs, height: lhs.height * rhs) 86 | } 87 | 88 | /** 89 | Implements the addition for the `Size` instances 90 | 91 | - parameter lhs: the first instance 92 | - parameter rhs: the second instance 93 | - returns: an instance of `Size` where the width and the height are the sum of the insets of 94 | the two operators 95 | */ 96 | public static func + (lhs: Size, rhs: Size) -> Size { 97 | return Size(width: lhs.width + rhs.width, height: lhs.height + rhs.height) 98 | } 99 | 100 | /** 101 | Implements the division for the `Size` instances 102 | 103 | - parameter lhs: the first instance 104 | - parameter rhs: the value that will be used in the division 105 | - returns: an instance of `Size` where the insets are divided by `rhs` 106 | */ 107 | public static func / (lhs: Size, rhs: CGFloat) -> Size { 108 | return Size(width: lhs.width / rhs, height: lhs.height / rhs) 109 | } 110 | 111 | /** 112 | Imlementation of the `Equatable` protocol. 113 | 114 | - parameter lhs: the first instance 115 | - parameter rhs: the second instance 116 | - returns: true if the two instances are equal, which means that both width and height are equal 117 | */ 118 | public static func == (lhs: Size, rhs: Size) -> Bool { 119 | return lhs.width == rhs.width && lhs.height == rhs.height 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /KatanaTests/Core/NodeDescriptionShouldUpdateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodeDescriptionShouldUpdateTests.swift 3 | // Katana 4 | // 5 | // Created by Mauro Bolis on 18/01/2017. 6 | // Copyright © 2017 Bending Spoons. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | import UIKit 12 | import KatanaUI 13 | import Katana 14 | 15 | fileprivate struct DescriptionProps: NodeDescriptionProps { 16 | var frame: CGRect = CGRect.zero 17 | var key: String? 18 | var alpha: CGFloat = 1.0 19 | 20 | var i: Int 21 | 22 | static func == (lhs: DescriptionProps, rhs: DescriptionProps) -> Bool { 23 | return lhs.frame == rhs.frame && lhs.i == rhs.i 24 | } 25 | 26 | init(i: Int) { 27 | self.i = i 28 | } 29 | } 30 | 31 | fileprivate struct Description: NodeDescription { 32 | static var invoked: (() -> ())? = nil 33 | typealias NativeView = UIView 34 | 35 | var props: DescriptionProps 36 | var children: [AnyNodeDescription] = [] 37 | 38 | init(props: DescriptionProps) { 39 | self.props = props 40 | } 41 | 42 | public static func childrenDescriptions(props: DescriptionProps, 43 | state: EmptyState, 44 | update: @escaping (EmptyState) -> (), 45 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 46 | 47 | Description.invoked?() 48 | return [] 49 | } 50 | } 51 | 52 | fileprivate struct CustomDescription: NodeDescription { 53 | static var invoked: (() -> ())? = nil 54 | typealias NativeView = UIView 55 | 56 | var props: DescriptionProps 57 | var children: [AnyNodeDescription] = [] 58 | 59 | init(props: DescriptionProps) { 60 | self.props = props 61 | } 62 | 63 | public static func childrenDescriptions(props: DescriptionProps, 64 | state: EmptyState, 65 | update: @escaping (EmptyState) -> (), 66 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 67 | 68 | CustomDescription.invoked?() 69 | return [] 70 | } 71 | 72 | public static func shouldUpdate(currentProps: DescriptionProps, 73 | nextProps: DescriptionProps, 74 | currentState: EmptyState, 75 | nextState: EmptyState) -> Bool { 76 | 77 | if nextProps.i == 999 { 78 | return false 79 | } 80 | 81 | return currentProps != nextProps 82 | } 83 | } 84 | 85 | class NodeDescriptionShouldUpdateTests: XCTestCase { 86 | func testDefaultBehaviour() { 87 | var invoked = false 88 | 89 | Description.invoked = { 90 | invoked = true 91 | } 92 | 93 | let renderer = Renderer(rootDescription: Description(props: DescriptionProps(i: 100)), store: nil) 94 | renderer.render(in: UIView()) 95 | 96 | XCTAssert(invoked) 97 | 98 | // trigger an identical update 99 | invoked = false 100 | renderer.rootNode.update(with: Description(props: DescriptionProps(i: 100))) 101 | XCTAssertFalse(invoked) 102 | 103 | // trigger a different update 104 | invoked = false 105 | renderer.rootNode.update(with: Description(props: DescriptionProps(i: 99))) 106 | XCTAssert(invoked) 107 | } 108 | 109 | func testCustomUpdate() { 110 | var invoked = false 111 | 112 | CustomDescription.invoked = { 113 | invoked = true 114 | } 115 | 116 | let renderer = Renderer(rootDescription: CustomDescription(props: DescriptionProps(i: 100)), store: nil) 117 | renderer.render(in: UIView()) 118 | 119 | XCTAssert(invoked) 120 | 121 | // trigger an identical update 122 | invoked = false 123 | renderer.rootNode.update(with: CustomDescription(props: DescriptionProps(i: 100))) 124 | XCTAssertFalse(invoked) 125 | 126 | // trigger a different update 127 | invoked = false 128 | renderer.rootNode.update(with: CustomDescription(props: DescriptionProps(i: 99))) 129 | XCTAssert(invoked) 130 | 131 | // trigger a 999 update 132 | invoked = false 133 | renderer.rootNode.update(with: CustomDescription(props: DescriptionProps(i: 999))) 134 | XCTAssertFalse(invoked) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Demo/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 26 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | This document contains information and guidelines about contributing to this project. 3 | Please read it before you start participating. 4 | 5 | **Topics** 6 | 7 | * [Pull Request Submissions](#pull-request-submissions) 8 | * [Asking Questions](#asking-questions) 9 | * [Reporting Issues](#reporting-issues) 10 | * [Developers Certificate of Origin](#developers-certificate-of-origin) 11 | * [Code of Conduct](#code-of-conduct) 12 | 13 | 14 | ## Pull Request Submissions. 15 | Pull Requests should contain the issue you are solving as well as a description about your implementation/solution. Before submitting a pull request, please check that merge issues are resolved. 16 | 17 | We use [Swiftlint](https://github.com/realm/SwiftLint) to enforce coding rules. The rules can be found in the `.swiftlint.yml` file in the root folder of the project. Please make sure you don't have any warning or error before submitting the Pull Request. 18 | 19 | Documentation and tests are very important. When you submit a Pull Request, please keep the documentation updated and Implement the appropriated tests. For instance, if you fix a bug, a good idea is to add tests that are aimed to confirm that the issue has been solved and avoid future regressions. 20 | 21 | 22 | ## Asking Questions 23 | Feel free to [open an issue](https://github.com/BendingSpoons/katana-ui-swift/issues/new) for any doubt or feature request related to Katana UI. 24 | 25 | For any usage questions that are not specific to the project itself (e.g., issus related to Swift), please ask on [Stack Overflow](http://stackoverflow.com/questions/tagged/swift) instead. By doing so, you'll be more likely to quickly solve your problem, and you'll allow anyone else with the same question to find the answer. This also allows maintainers to focus on improving the project for others. 26 | 27 | 28 | ## Reporting Issues 29 | A great way to contribute to the project 30 | is to send a detailed issue when you encounter a problem. 31 | We always appreciate a well-written, thorough bug report. 32 | 33 | Check that the project issues database 34 | doesn't already include that problem or suggestion before submitting an issue. 35 | If you find a match, add a quick "+1" or "I have this problem too." 36 | Doing this helps prioritize the most common problems and requests. 37 | 38 | When reporting issues, please include the following: 39 | 40 | * The version of Xcode you're using 41 | * The version of iOS you're targeting 42 | * The full output of any stack trace or compiler error 43 | * A code snippet that reproduces the described behavior, if applicable 44 | * Any other details that would be useful in understanding the problem 45 | 46 | This information will help us review and fix your issue faster. 47 | 48 | 49 | ## Developer's Certificate of Origin 1.1 50 | By making a contribution to this project, I certify that: 51 | 52 | - (a) The contribution was created in whole or in part by me and I 53 | have the right to submit it under the open source license 54 | indicated in the file; or 55 | 56 | - (b) The contribution is based upon previous work that, to the best 57 | of my knowledge, is covered under an appropriate open source 58 | license and I have the right under that license to submit that 59 | work with modifications, whether created in whole or in part 60 | by me, under the same open source license (unless I am 61 | permitted to submit under a different license), as indicated 62 | in the file; or 63 | 64 | - (c) The contribution was provided directly to me by some other 65 | person who certified (a), (b) or (c) and I have not modified 66 | it. 67 | 68 | - (d) I understand and agree that this project and the contribution 69 | are public and that a record of the contribution (including all 70 | personal information I submit with it, including my sign-off) is 71 | maintained indefinitely and may be redistributed consistent with 72 | this project or the open source license(s) involved. 73 | 74 | --- 75 | 76 | *Some of the ideas and wording for the statements above were based on work by the [Alamofire](https://github.com/Alamofire/Alamofire/blob/master/CONTRIBUTING.md), [Docker](https://github.com/docker/docker/blob/master/CONTRIBUTING.md) and [Linux](http://elinux.org/Developer_Certificate_Of_Origin) communities. We commend them for their efforts to facilitate collaboration in their projects.* 77 | -------------------------------------------------------------------------------- /KatanaUI/Plastic/PlasticNodeDescription.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlasticNodeDescription.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | 11 | /// Type Erasure for `PlasticNodeDescription` 12 | public protocol AnyPlasticNodeDescription { 13 | /** 14 | Implements the layout logic of the node description 15 | 16 | - seeAlso: `PlasticNodeDescription`, `layout(views:props:state:)` method 17 | */ 18 | static func anyLayout(views: Any, props: Any, state: Any) 19 | } 20 | 21 | /** 22 | This protocol allows a `NodeDescription` to use the Plastic layout system. 23 | */ 24 | public protocol PlasticNodeDescription: AnyPlasticNodeDescription, NodeDescription { 25 | /** 26 | Node descriptions should implement this method and provide the proper layout logic. 27 | 28 | This method receives as input, beside the props and the state of the node description, 29 | a `ViewsContainer` that basically holds a placeholder for each node description with a key 30 | returned in the `childrenDescriptions` method. You can access these placeholders by using the 31 | same key. 32 | 33 | The following is an example of implementation: 34 | 35 | ``` 36 | // in the childrenDescriptions method 37 | return [ 38 | View(props: ViewProps(key: Keys.view)) 39 | ] 40 | 41 | // in the layout method 42 | // we make view with key `.view` as big as the native view 43 | views[.view]?.fill(views.nativeView) 44 | ``` 45 | 46 | - parameter views: the container that holds the node description children 47 | - parameter props: the current props of the node description 48 | - parameter state: the current state of the node description 49 | */ 50 | static func layout(views: ViewsContainer, props: PropsType, state: StateType) 51 | 52 | /** 53 | The layout logic may be expensive in some cases. By implementing this method, you can actually 54 | cache the result of the layout. 55 | 56 | The idea is that, given a certain instance of props and state, you should return an hash value. 57 | The calculated hash value will be used to store and retrieve the result of the layout logic. 58 | This means that if you return an hash value that has been stored before, the `layout(views:props:state:)` 59 | method won't be invoked and the cached result will be used instead. 60 | 61 | Ideally you should somehow combine all the values (taken from both the props and the state) that influence 62 | the result of your layout logic. In the case where the layout is always the same (that is, frames never change), 63 | then you can return a constant value. 64 | 65 | The caching system is disabled by default. You can activate it for a specific implementation of `PlasticNodeDescription` 66 | by implementing this method. 67 | 68 | - parameter props: the props that will be used in the layout 69 | - parameter state: the state that will be used in the layout 70 | - returns: an hash value with the properties described in the description of the method 71 | */ 72 | static func layoutHash(props: PropsType, state: StateType) -> Int? 73 | } 74 | 75 | public extension PlasticNodeDescription { 76 | /** 77 | Implementation of the `AnyPlasticNodeDescription` protocol. 78 | 79 | - seeAlso: `AnyPlasticNodeDescription` 80 | */ 81 | static func anyLayout(views: Any, props: Any, state: Any) { 82 | if let p = props as? PropsType, let s = state as? StateType, let v = views as? ViewsContainer { 83 | layout(views: v, props: p, state: s) 84 | } 85 | } 86 | 87 | /// default value is `nil` 88 | static func layoutHash(props: PropsType, state: StateType) -> Int? { 89 | return nil 90 | } 91 | 92 | /** 93 | Creates an instance of `Node` given the parent `Node`. 94 | 95 | This method is the same as the `NodeDescription` `makeNode(parent:)` but it 96 | returns an instance of `PlasticNode` 97 | */ 98 | public func makeNode(parent: AnyNode) -> AnyNode { 99 | return PlasticNode(description: self, parent: parent) 100 | } 101 | 102 | /** 103 | Creates an instance of `Node` given the `Renderer` responsible to render the Nodes tree 104 | starting from this Plastic Node Description. 105 | 106 | This method is the same as the `NodeDescription` `makeNode(renderer:)` but it 107 | returns an instance of `PlasticNode` 108 | */ 109 | public func makeNode(renderer: Renderer) -> AnyNode { 110 | return PlasticNode(description: self, renderer: renderer) 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /Examples/Minesweeper/Minesweeper/UI/MinesweeperCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinesweeperCell.swift 3 | // Minesweeper 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Katana 10 | import KatanaUI 11 | import KatanaElements 12 | 13 | // MARK: - NodeDescription 14 | struct MinesweeperCell: PlasticNodeDescription, ConnectedNodeDescription { 15 | typealias StateType = EmptyState 16 | typealias PropsType = Props 17 | typealias NativeView = UIView 18 | typealias Keys = ChildrenKeys 19 | 20 | var props: Props 21 | 22 | static func childrenDescriptions(props: PropsType, 23 | state: StateType, 24 | update: @escaping (StateType) -> (), 25 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 26 | 27 | func discloseMinesweeperCell() { 28 | dispatch(DiscloseCell(payload: (col: props.col, row: props.row))) 29 | } 30 | 31 | let disclosed: Bool = props.disclosed 32 | 33 | var labelText: String 34 | if props.hasMine { 35 | labelText = props.hasMine ? "+" : "" 36 | } else if props.minesNearby > 0 { 37 | labelText = String(props.minesNearby) 38 | } else { 39 | labelText = "" 40 | } 41 | 42 | let textColor = colorForNumber(props.minesNearby) 43 | let textAttributes = [NSForegroundColorAttributeName: textColor, NSFontAttributeName: UIFont.boldSystemFont(ofSize: 16)] 44 | 45 | let mineImage = Image(props: Image.Props.build({ 46 | $0.setKey(Keys.mineImage) 47 | $0.image = #imageLiteral(resourceName: "mine") 48 | $0.backgroundColor = .red 49 | })) 50 | 51 | let label = Label(props: Label.Props.build({ 52 | $0.setKey(Keys.label) 53 | $0.text = NSAttributedString(string: labelText, attributes: textAttributes) 54 | $0.textAlignment = .center 55 | $0.borderWidth = .scalable(1.0) 56 | $0.borderColor = .gray 57 | })) 58 | 59 | let button = Button(props: Button.Props.build({ 60 | $0.setKey(Keys.button) 61 | $0.titleColors = [.normal: .gray] 62 | $0.backgroundColor = disclosed ? .white : .lightGray 63 | $0.touchHandlers = [TouchHandlerEvent.touchUpInside: discloseMinesweeperCell] 64 | $0.borderWidth = .scalable(1.0) 65 | $0.borderColor = .gray 66 | })) 67 | 68 | if props.hasMine && props.disclosed { 69 | return [mineImage] 70 | } 71 | return disclosed ? [label] : [label, button] 72 | } 73 | 74 | static func layout(views: ViewsContainer, props: PropsType, state: StateType) { 75 | let root = views.nativeView 76 | let label = views[.label] 77 | let button = views[.button] 78 | let mineImage = views[.mineImage] 79 | 80 | label?.fill(root) 81 | button?.fill(root) 82 | mineImage?.fill(root) 83 | } 84 | 85 | static func connect(props: inout PropsType, to storeState: MinesweeperState) { 86 | let column = props.col 87 | let row = props.row 88 | props.hasMine = storeState[column, row] 89 | props.disclosed = storeState.isDisclosed(col: column, row: row) 90 | props.minesNearby = storeState.minesNearbyCellAt(col: column, row: row) 91 | } 92 | } 93 | 94 | // MARK: Keys and Props 95 | extension MinesweeperCell { 96 | enum ChildrenKeys { 97 | case button, label, mineImage 98 | } 99 | 100 | struct Props: NodeDescriptionProps, Buildable { 101 | public var frame: CGRect = .zero 102 | public var alpha: CGFloat = 1.0 103 | public var key: String? 104 | 105 | public var hasMine: Bool = false 106 | public var minesNearby: Int = 0 107 | public var disclosed: Bool = false 108 | public var col: Int = 0 109 | public var row: Int = 0 110 | 111 | static func == (lhs: PropsType, rhs: PropsType) -> Bool { 112 | return 113 | lhs.frame == rhs.frame && 114 | lhs.alpha == rhs.alpha && 115 | lhs.key == rhs.key && 116 | lhs.hasMine == rhs.hasMine && 117 | lhs.minesNearby == rhs.minesNearby && 118 | lhs.disclosed == rhs.disclosed && 119 | lhs.row == rhs.row && 120 | lhs.col == rhs.col 121 | } 122 | } 123 | } 124 | 125 | // MARK: - Utils 126 | extension MinesweeperCell { 127 | static func colorForNumber(_ number: Int) -> UIColor { 128 | switch number { 129 | case 1: 130 | return .blue 131 | case 2: 132 | return .green 133 | case 3: 134 | return .red 135 | default: 136 | return .red 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /KatanaUI/Plastic/LayoutsCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LayoutsCache.swift 3 | // Katana 4 | // 5 | // Copyright © 2016 Bending Spoons. 6 | // Distributed under the MIT License. 7 | // See the LICENSE file for more information. 8 | 9 | import Foundation 10 | import CoreGraphics 11 | 12 | /// `CGSize` extension that makes it hashable 13 | extension CGSize: Hashable { 14 | public var hashValue: Int { 15 | return 16 | self.width.hashValue ^ 17 | self.height.hashValue 18 | } 19 | } 20 | 21 | /** 22 | Struct used as key of the layout cache. 23 | Every instance is related to node description in a given context. 24 | The key captures the part of the context that is relevant to understand 25 | whether a cached layout can be reused or not 26 | */ 27 | fileprivate struct CacheKey: Hashable { 28 | /// The native view size of the node description 29 | let nativeViewSize: CGSize 30 | 31 | /// The multiplier used to calculate the layout 32 | let multiplier: CGFloat 33 | 34 | /// The layout hash returned by the node description 35 | let layoutHash: Int 36 | 37 | /// The node description hash 38 | let nodeDescriptionHash: Int 39 | 40 | /// the hash value of the instance 41 | var hashValue: Int { 42 | return 43 | self.nativeViewSize.hashValue ^ 44 | self.multiplier.hashValue ^ 45 | self.layoutHash ^ 46 | self.nodeDescriptionHash 47 | } 48 | 49 | /** 50 | Imlementation of the `Equatable` protocol. 51 | 52 | - parameter lhs: the first instance 53 | - parameter rhs: the second instance 54 | - returns: true if the two instances are equal 55 | */ 56 | static func == (l: CacheKey, r: CacheKey) -> Bool { 57 | return 58 | l.nodeDescriptionHash == r.nodeDescriptionHash && 59 | l.nativeViewSize == r.nativeViewSize && 60 | l.multiplier == r.multiplier && 61 | l.layoutHash == r.layoutHash 62 | } 63 | } 64 | 65 | /// A container for all the application cached layouts 66 | class LayoutsCache { 67 | 68 | /// Singleton to use in the application 69 | static let shared = LayoutsCache() 70 | 71 | /// A dictionary that contains the cached layout results 72 | fileprivate var cache: [CacheKey: [String: CGRect]] 73 | 74 | /// Init for the cache 75 | private init() { 76 | self.cache = [:] 77 | } 78 | 79 | /** 80 | Add a new layout result to the cache 81 | 82 | - parameter layoutHash: the hash returned by the `PlasticNodeDescription` `layout(props:state:)` method 83 | - parameter nativeViewFrame: the frame of the native view used to calculate the layout 84 | - parameter multiplier: the multiplier used to calculate the layout 85 | - parameter nodeDescription: the node description for which the layout has been calculated 86 | - parameter frames: the result of the layout operation 87 | */ 88 | func cacheLayout(layoutHash: Int, 89 | nativeViewFrame: CGRect, 90 | multiplier: CGFloat, 91 | nodeDescription: AnyNodeDescription, 92 | frames: [String: CGRect]) { 93 | 94 | let nodeHash = ObjectIdentifier(type(of: nodeDescription)).hashValue 95 | 96 | let key = CacheKey( 97 | nativeViewSize: nativeViewFrame.size, 98 | multiplier: multiplier, 99 | layoutHash: layoutHash, 100 | nodeDescriptionHash: nodeHash 101 | ) 102 | 103 | self.cache[key] = frames 104 | } 105 | 106 | /** 107 | Retrieves the a cached layout result that is compatible with the given parameters 108 | 109 | - parameter layoutHash: the hash returned by the `PlasticNodeDescription` `layout(props:state:)` method 110 | - parameter nativeViewFrame: the frame of the native view for which we want the layout cache 111 | - parameter multiplier: the multiplier for which we want the layout cache 112 | - parameter nodeDescription: the node description for which we want the layout cache 113 | - returns: If it is possible to retrieve a layout cache, a dictionary where the key is the key 114 | of the node description and the value is the frame to use for that node description. 115 | `nil` otherwise. 116 | */ 117 | func getCachedLayout(layoutHash: Int, 118 | nativeViewFrame: CGRect, 119 | multiplier: CGFloat, 120 | nodeDescription: AnyNodeDescription) -> [String: CGRect]? { 121 | 122 | let nodeHash = ObjectIdentifier(type(of: nodeDescription)).hashValue 123 | 124 | let key = CacheKey( 125 | nativeViewSize: nativeViewFrame.size, 126 | multiplier: multiplier, 127 | layoutHash: layoutHash, 128 | nodeDescriptionHash: nodeHash 129 | ) 130 | 131 | return self.cache[key] 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /KatanaTests/Core/NodeTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import KatanaUI 3 | import UIKit 4 | import Katana 5 | 6 | class NodeTest: XCTestCase { 7 | func testNodeDeallocation() { 8 | let renderer = Renderer(rootDescription: App(props: AppProps(i:0)), store: nil) 9 | let node = renderer.rootNode! 10 | 11 | var references = collectNodes(node: node).map { WeakNode(value: $0) } 12 | XCTAssert(references.count == 3) 13 | XCTAssert(references.filter { $0.value != nil }.count == 3) 14 | 15 | node.update(with: App(props: AppProps(i:1))) 16 | XCTAssert(references.count == 3) 17 | XCTAssertEqual(references.filter { $0.value != nil }.count, 2) 18 | 19 | references = collectNodes(node: node).map { WeakNode(value: $0) } 20 | XCTAssert(references.count == 2) 21 | XCTAssertEqual(references.filter { $0.value != nil }.count, 2) 22 | 23 | node.update(with: App(props: AppProps(i:2))) 24 | XCTAssert(references.count == 2) 25 | XCTAssertEqual(references.filter { $0.value != nil }.count, 0) 26 | 27 | references = collectNodes(node: node).map { WeakNode(value: $0) } 28 | XCTAssert(references.isEmpty) 29 | XCTAssertEqual(references.filter { $0.value != nil }.count, 0) 30 | } 31 | 32 | func testViewDeallocation() { 33 | let renderer = Renderer(rootDescription: App(props: AppProps(i:0)), store: nil) 34 | let node = renderer.rootNode! 35 | 36 | let rootVew = UIView() 37 | renderer.render(in: rootVew) 38 | 39 | var references = collectView(view: rootVew) 40 | .filter { $0.tagValue == KatanaUI.VIEWTAG } 41 | .map { WeakView(value: $0) } 42 | 43 | autoreleasepool { 44 | node.update(with: App(props: AppProps(i:2))) 45 | } 46 | 47 | XCTAssertEqual(references.filter { $0.value != nil }.count, 1) 48 | 49 | references = collectView(view: rootVew) 50 | .filter { $0.tagValue == KatanaUI.VIEWTAG } 51 | .map { WeakView(value: $0) } 52 | 53 | XCTAssertEqual(references.count, 1) 54 | } 55 | 56 | } 57 | 58 | fileprivate struct MyAppState: State {} 59 | 60 | fileprivate struct AppProps: NodeDescriptionProps { 61 | var frame: CGRect = CGRect.zero 62 | var key: String? 63 | var alpha: CGFloat = 1.0 64 | 65 | var i: Int 66 | 67 | static func == (lhs: AppProps, rhs: AppProps) -> Bool { 68 | return lhs.frame == rhs.frame && lhs.i == rhs.i 69 | } 70 | 71 | init(i: Int) { 72 | self.i = i 73 | } 74 | } 75 | 76 | fileprivate struct App: NodeDescription { 77 | typealias NativeView = UIView 78 | 79 | var props: AppProps 80 | var children: [AnyNodeDescription] = [] 81 | 82 | init(props: AppProps) { 83 | self.props = props 84 | } 85 | 86 | public static func childrenDescriptions(props: AppProps, 87 | state: EmptyState, 88 | update: @escaping (EmptyState) -> (), 89 | dispatch: @escaping StoreDispatch) -> [AnyNodeDescription] { 90 | 91 | let i = props.i 92 | 93 | if i == 0 { 94 | var imageProps = ImageProps() 95 | imageProps.backgroundColor = .blue 96 | let image = Image(props: imageProps) 97 | 98 | var innerViewProps = ViewProps() 99 | innerViewProps.frame = CGRect(x: 0, y: 0, width: 150, height: 150) 100 | innerViewProps.backgroundColor = .gray 101 | let innerView = View(props: innerViewProps) 102 | 103 | var viewProps = ViewProps() 104 | viewProps.frame = CGRect(x: 0, y: 0, width: 150, height: 150) 105 | viewProps.backgroundColor = .gray 106 | viewProps.children = [image, innerView] 107 | let view = View(props: viewProps) 108 | 109 | return [view] 110 | 111 | } else if i == 1 { 112 | 113 | var imageProps = ImageProps() 114 | imageProps.backgroundColor = .blue 115 | let image = Image(props: imageProps) 116 | 117 | var viewProps = ViewProps() 118 | viewProps.frame = CGRect(x: 0, y: 0, width: 150, height: 150) 119 | viewProps.backgroundColor = .gray 120 | viewProps.children = [image] 121 | let view = View(props: viewProps) 122 | 123 | return [view] 124 | 125 | } else { 126 | return [] 127 | } 128 | 129 | } 130 | } 131 | 132 | fileprivate class WeakNode { 133 | weak var value: AnyNode? 134 | init(value: AnyNode) { 135 | self.value = value 136 | } 137 | } 138 | 139 | fileprivate class WeakView { 140 | weak var value: UIView? 141 | init(value: UIView) { 142 | self.value = value 143 | } 144 | } 145 | 146 | fileprivate func collectNodes(node: AnyNode) -> [AnyNode] { 147 | return (node.children.map { collectNodes(node: $0) }.reduce([], { $0 + $1 })) + node.children 148 | } 149 | 150 | fileprivate func collectView(view: UIView) -> [UIView] { 151 | return (view.subviews.map { collectView(view: $0) }.reduce([], { $0 + $1 })) + view.subviews 152 | } 153 | --------------------------------------------------------------------------------