├── .swift-version ├── Cartfile ├── Cartfile.resolved ├── assets ├── emoji.gif ├── form.gif ├── hello.png ├── kyoto.gif ├── todo.gif ├── logo │ ├── logo.ai │ └── png │ │ ├── logo_carbon_bnr1_black.png │ │ ├── logo_carbon_bnr1_white.png │ │ ├── logo_carbon_bnr2_black.png │ │ ├── logo_carbon_bnr2_white.png │ │ ├── logo_carbon_bnr3_black.png │ │ ├── logo_carbon_bnr3_white.png │ │ ├── logo_carbon_logo_white.png │ │ ├── logo_carbon_logo2_white.png │ │ ├── logo_carbon_logo_black-02.png │ │ └── logo_carbon_logo_black-04.png ├── pangram.gif └── content-xib.png ├── docs ├── img │ ├── dash.png │ ├── gh.png │ ├── carat.png │ └── spinner.gif ├── badge.svg └── js │ ├── jazzy.js │ └── jazzy.search.js ├── Examples └── Example-iOS │ ├── Sources │ ├── Todo │ │ ├── Deletable.swift │ │ ├── Todo.swift │ │ ├── TodoEmpty.swift │ │ ├── TodoSwipeCellKitAdapter.swift │ │ ├── TodoText.swift │ │ └── TodoEmptyContent.xib │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── Trash.imageset │ │ │ ├── Trash.pdf │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── 1024.png │ │ │ ├── 120.png │ │ │ ├── 180.png │ │ │ └── Contents.json │ │ ├── KyotoGion.imageset │ │ │ ├── KyotoGion.jpg │ │ │ └── Contents.json │ │ ├── KyotoByōdōIn.imageset │ │ │ ├── KyotoByōdōIn.jpg │ │ │ └── Contents.json │ │ ├── KyotoKinkakuJi.imageset │ │ │ ├── KyotoKinkakuJi.jpg │ │ │ └── Contents.json │ │ ├── KyotoArashiyama.imageset │ │ │ ├── KyotoArashiyama.jpg │ │ │ └── Contents.json │ │ ├── KyotoFushimiInari.imageset │ │ │ ├── KyotoFushimiInari.jpg │ │ │ └── Contents.json │ │ ├── KyotoKiyomizuDera.imageset │ │ │ ├── KyotoKiyomizuDera.jpg │ │ │ └── Contents.json │ │ └── DisclosureIndicator.imageset │ │ │ ├── DisclosureIndicator.pdf │ │ │ └── Contents.json │ ├── Common │ │ ├── HostingController.swift │ │ ├── Spacing.swift │ │ ├── Extensions.swift │ │ ├── Footer.swift │ │ ├── Header.swift │ │ ├── NibLoadable.swift │ │ ├── FooterContent.xib │ │ └── HeaderContent.xib │ ├── Kyoto │ │ ├── KyotoTop.swift │ │ ├── KyotoImage.swift │ │ ├── KyotoLicense.swift │ │ ├── KyotoViewController.swift │ │ ├── KyotoLicenseContent.xib │ │ ├── KyotoViewController.xib │ │ ├── KyotoMagazineLayoutAdapter.swift │ │ └── KyotoImageContent.xib │ ├── Hello │ │ ├── HelloMessage.swift │ │ ├── HelloViewController.swift │ │ ├── HelloViewController.xib │ │ └── HelloMessageContent.xib │ ├── Pangram │ │ ├── PangramLabel.swift │ │ └── PangramViewController.swift │ ├── Form │ │ ├── FormTextView.swift │ │ ├── FormSwitch.swift │ │ ├── FormDatePicker.swift │ │ ├── FormLabel.swift │ │ ├── FormTextField.swift │ │ ├── FormTextPicker.swift │ │ ├── FormTextPickerContent.xib │ │ ├── FormDatePickerContent.xib │ │ ├── FormViewController.xib │ │ ├── FormTextViewContent.xib │ │ └── FormTextFieldContent.xib │ ├── AppDelegate.swift │ ├── Emoji │ │ ├── EmojiLabel.swift │ │ ├── EmojiViewController.swift │ │ └── EmojiLabelContent.xib │ ├── Top │ │ ├── HomeItem.swift │ │ ├── HomeViewController.swift │ │ └── HomeViewController.xib │ ├── KyotoSwiftUI │ │ └── KyotoSwiftUIView.swift │ ├── Info.plist │ └── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── README.md │ └── Example-iOS.xcodeproj │ ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved │ └── xcshareddata │ └── xcschemes │ └── Example-iOS.xcscheme ├── Gemfile ├── .hound.yml ├── .gitmodules ├── Carbon.playground ├── playground.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── contents.xcplayground └── Contents.swift ├── Carbon.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── Tests.xcscheme │ └── Carbon.xcscheme ├── Carbon.xcworkspace ├── xcshareddata │ └── IDEWorkspaceChecks.plist └── contents.xcworkspacedata ├── Sources ├── DataChangeset.swift ├── FunctionBuilder │ ├── CellsBuildable.swift │ ├── SectionsBuildable.swift │ └── Group.swift ├── Interfaces │ ├── UICollectionComponentReusableView.swift │ ├── UICollectionViewComponentCell.swift │ ├── UITableViewComponentHeaderFooterView.swift │ ├── UITableViewComponentCell.swift │ └── ComponentRenderable.swift ├── Info.plist ├── Internal │ ├── RuntimeAssociation.swift │ └── UIScrollViewExtensions.swift ├── Updaters │ ├── Updater.swift │ ├── UITableViewReloadDataUpdater.swift │ └── UICollectionViewReloadDataUpdater.swift ├── IdentifiableComponent.swift ├── Nodes │ ├── ViewNode.swift │ └── CellNode.swift ├── ComponentWrapper │ └── IdentifiedComponentWrapper.swift ├── Adapters │ ├── Adapter.swift │ └── UICollectionViewFlowLayoutAdapter.swift ├── SwiftUISupport │ └── ComponentSwiftUISupport.swift └── Renderer.swift ├── Makefile ├── Package.resolved ├── .gitignore ├── .swiftlint.yml ├── Tests ├── IdentifiableComponentTests.swift ├── Info.plist ├── Nodes │ ├── ViewNodeTests.swift │ └── CellNodeTests.swift ├── ComponentWrapper │ ├── IdentifiedComponentWrapperTests.swift │ └── ComponentWrappingTests.swift ├── Updater │ ├── UITableViewReloadDataUpdaterTests.swift │ └── UICollectionViewReloadDataTests.swift ├── FunctionBuilder │ ├── SectionsBuildableTests.swift │ ├── CellsBuildableTests.swift │ └── GroupTests.swift ├── Adapters │ ├── MockCustomXibCollectionViewReusableView.xib │ ├── MockCustomXibTableViewHeaderFooterView.xib │ ├── MockCustomXibTableViewCell.xib │ └── MockCustomXibCollectionViewCell.xib ├── ComponentTests.swift ├── Interfaces │ └── ComponentContainerElementTests.swift ├── Internal │ └── UIScrollViewExtensionsTests.swift └── SwiftUISupport │ └── ComponentSwiftUISupportTests.swift ├── .github ├── ISSUE_TEMPLATE │ ├── FEATURE_REQUEST.md │ ├── QUESTION.md │ └── BUG_REPORT.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yml ├── XCConfigs └── Carbon.xcconfig ├── Package.swift ├── Carbon.podspec ├── .jazzy.yml ├── Gemfile.lock ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md /.swift-version: -------------------------------------------------------------------------------- 1 | 5.1 2 | -------------------------------------------------------------------------------- /Cartfile: -------------------------------------------------------------------------------- 1 | github "ra1028/DifferenceKit" ~> 1.1 2 | -------------------------------------------------------------------------------- /Cartfile.resolved: -------------------------------------------------------------------------------- 1 | github "ra1028/DifferenceKit" "1.1.4" 2 | -------------------------------------------------------------------------------- /assets/emoji.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/emoji.gif -------------------------------------------------------------------------------- /assets/form.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/form.gif -------------------------------------------------------------------------------- /assets/hello.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/hello.png -------------------------------------------------------------------------------- /assets/kyoto.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/kyoto.gif -------------------------------------------------------------------------------- /assets/todo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/todo.gif -------------------------------------------------------------------------------- /docs/img/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/docs/img/dash.png -------------------------------------------------------------------------------- /docs/img/gh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/docs/img/gh.png -------------------------------------------------------------------------------- /assets/logo/logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/logo/logo.ai -------------------------------------------------------------------------------- /assets/pangram.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/pangram.gif -------------------------------------------------------------------------------- /docs/img/carat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/docs/img/carat.png -------------------------------------------------------------------------------- /assets/content-xib.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/content-xib.png -------------------------------------------------------------------------------- /docs/img/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/docs/img/spinner.gif -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Todo/Deletable.swift: -------------------------------------------------------------------------------- 1 | protocol Deletable { 2 | func delete() 3 | } 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'cocoapods', '1.7.5' 4 | gem 'jazzy', '0.10.0' 5 | -------------------------------------------------------------------------------- /assets/logo/png/logo_carbon_bnr1_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/logo/png/logo_carbon_bnr1_black.png -------------------------------------------------------------------------------- /assets/logo/png/logo_carbon_bnr1_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/logo/png/logo_carbon_bnr1_white.png -------------------------------------------------------------------------------- /assets/logo/png/logo_carbon_bnr2_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/logo/png/logo_carbon_bnr2_black.png -------------------------------------------------------------------------------- /assets/logo/png/logo_carbon_bnr2_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/logo/png/logo_carbon_bnr2_white.png -------------------------------------------------------------------------------- /assets/logo/png/logo_carbon_bnr3_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/logo/png/logo_carbon_bnr3_black.png -------------------------------------------------------------------------------- /assets/logo/png/logo_carbon_bnr3_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/logo/png/logo_carbon_bnr3_white.png -------------------------------------------------------------------------------- /assets/logo/png/logo_carbon_logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/logo/png/logo_carbon_logo_white.png -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | swiftlint: 2 | config_file: .swiftlint.yml 3 | 4 | ruby: 5 | enabled: false 6 | 7 | javascript: 8 | enabled: false 9 | -------------------------------------------------------------------------------- /assets/logo/png/logo_carbon_logo2_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/logo/png/logo_carbon_logo2_white.png -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /assets/logo/png/logo_carbon_logo_black-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/logo/png/logo_carbon_logo_black-02.png -------------------------------------------------------------------------------- /assets/logo/png/logo_carbon_logo_black-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/assets/logo/png/logo_carbon_logo_black-04.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "Carthage/Checkouts/DifferenceKit"] 2 | path = Carthage/Checkouts/DifferenceKit 3 | url = https://github.com/ra1028/DifferenceKit.git 4 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Todo/Todo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Todo { 4 | typealias ID = UUID 5 | 6 | var id: ID 7 | var text: String 8 | } 9 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/Trash.imageset/Trash.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/Examples/Example-iOS/Sources/Assets.xcassets/Trash.imageset/Trash.pdf -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/Examples/Example-iOS/Sources/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/Examples/Example-iOS/Sources/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/Examples/Example-iOS/Sources/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /Examples/Example-iOS/README.md: -------------------------------------------------------------------------------- 1 | # How to Run 2 | 3 | 1. Setup project by command `make setup` in project root directory 4 | 1. Open `Example-iOS.xcodeproj` via Xcode 5 | 1. Run `Example-iOS` scheme 6 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/KyotoGion.imageset/KyotoGion.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/Examples/Example-iOS/Sources/Assets.xcassets/KyotoGion.imageset/KyotoGion.jpg -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/KyotoByōdōIn.imageset/KyotoByōdōIn.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/Examples/Example-iOS/Sources/Assets.xcassets/KyotoByōdōIn.imageset/KyotoByōdōIn.jpg -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/KyotoKinkakuJi.imageset/KyotoKinkakuJi.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/Examples/Example-iOS/Sources/Assets.xcassets/KyotoKinkakuJi.imageset/KyotoKinkakuJi.jpg -------------------------------------------------------------------------------- /Carbon.playground/playground.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/KyotoArashiyama.imageset/KyotoArashiyama.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/Examples/Example-iOS/Sources/Assets.xcassets/KyotoArashiyama.imageset/KyotoArashiyama.jpg -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/KyotoFushimiInari.imageset/KyotoFushimiInari.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/Examples/Example-iOS/Sources/Assets.xcassets/KyotoFushimiInari.imageset/KyotoFushimiInari.jpg -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/KyotoKiyomizuDera.imageset/KyotoKiyomizuDera.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/Examples/Example-iOS/Sources/Assets.xcassets/KyotoKiyomizuDera.imageset/KyotoKiyomizuDera.jpg -------------------------------------------------------------------------------- /Carbon.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Carbon.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/DisclosureIndicator.imageset/DisclosureIndicator.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/Carbon/HEAD/Examples/Example-iOS/Sources/Assets.xcassets/DisclosureIndicator.imageset/DisclosureIndicator.pdf -------------------------------------------------------------------------------- /Examples/Example-iOS/Example-iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/Trash.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Trash.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/KyotoGion.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "KyotoGion.jpg" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/KyotoByōdōIn.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "KyotoByōdōIn.jpg" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/KyotoArashiyama.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "KyotoArashiyama.jpg" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/KyotoKinkakuJi.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "KyotoKinkakuJi.jpg" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Common/HostingController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | final class HostingController: UIHostingController { 5 | override var supportedInterfaceOrientations: UIInterfaceOrientationMask { 6 | .portrait 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/KyotoFushimiInari.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "KyotoFushimiInari.jpg" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/KyotoKiyomizuDera.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "KyotoKiyomizuDera.jpg" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Assets.xcassets/DisclosureIndicator.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "DisclosureIndicator.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Carbon.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Carbon.playground/playground.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Carbon.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Example-iOS.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/DataChangeset.swift: -------------------------------------------------------------------------------- 1 | import DifferenceKit 2 | 3 | /// A set of changes in the collection of sections. 4 | public typealias DataChangeset = Changeset<[Section]> 5 | 6 | /// An ordered collection of `DataChangeset` as staged set of changes 7 | /// in the collection of sections. 8 | public typealias StagedDataChangeset = StagedChangeset<[Section]> 9 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Kyoto/KyotoTop.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Carbon 3 | 4 | struct KyotoTop: Component, View, Hashable { 5 | func renderContent() -> KyotoTopContent { 6 | .loadFromNib() 7 | } 8 | 9 | func render(in content: KyotoTopContent) {} 10 | } 11 | 12 | final class KyotoTopContent: UIView, NibLoadable {} 13 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Example-iOS.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | setup: 2 | git submodule update --init --recursive 3 | 4 | gems-install: 5 | bundle install --path vendor/bundle 6 | 7 | docs-gen: 8 | bundle exec jazzy --config .jazzy.yml --swift-version 5.1 -x USE_SWIFT_RESPONSE_FILE=NO 9 | 10 | lib-lint: 11 | bundle exec pod lib lint 12 | 13 | pod-release: 14 | bundle exec pod trunk push --allow-warnings 15 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "DifferenceKit", 6 | "repositoryURL": "https://github.com/ra1028/DifferenceKit.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "4eb31f8e85e4cb13732f9664d6e01e507cd592a0", 10 | "version": "1.1.3" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Carbon.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Sources/FunctionBuilder/CellsBuildable.swift: -------------------------------------------------------------------------------- 1 | /// Represents an instance that can build cells. 2 | public protocol CellsBuildable { 3 | /// Build an array of cell. 4 | func buildCells() -> [CellNode] 5 | } 6 | 7 | extension Optional: CellsBuildable where Wrapped: CellsBuildable { 8 | /// Build an array of cell. 9 | @inlinable 10 | public func buildCells() -> [CellNode] { 11 | return self?.buildCells() ?? [] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/FunctionBuilder/SectionsBuildable.swift: -------------------------------------------------------------------------------- 1 | /// Represents an instance that can build sections. 2 | public protocol SectionsBuildable { 3 | /// Build an array of section. 4 | func buildSections() -> [Section] 5 | } 6 | 7 | extension Optional: SectionsBuildable where Wrapped: SectionsBuildable { 8 | /// Build an array of section. 9 | @inlinable 10 | public func buildSections() -> [Section] { 11 | return self?.buildSections() ?? [] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Common/Spacing.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | struct Spacing: Component { 5 | var height: CGFloat 6 | 7 | init(_ height: CGFloat) { 8 | self.height = height 9 | } 10 | 11 | func renderContent() -> UIView { 12 | UIView() 13 | } 14 | 15 | func render(in content: UIView) {} 16 | 17 | func referenceSize(in bounds: CGRect) -> CGSize? { 18 | CGSize(width: bounds.width, height: height) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Todo/TodoEmpty.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | struct TodoEmpty: IdentifiableComponent, Hashable { 5 | func renderContent() -> TodoEmptyContent { 6 | .loadFromNib() 7 | } 8 | 9 | func render(in content: TodoEmptyContent) {} 10 | 11 | func referenceSize(in bounds: CGRect) -> CGSize? { 12 | CGSize(width: bounds.width, height: 150) 13 | } 14 | } 15 | 16 | final class TodoEmptyContent: UIView, NibLoadable { 17 | @IBOutlet var textLabel: UILabel! 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | */build/* 3 | *.pbxuser 4 | !default.pbxuser 5 | *.mode1v3 6 | !default.mode1v3 7 | *.mode2v3 8 | !default.mode2v3 9 | *.perspectivev3 10 | !default.perspectivev3 11 | xcuserdata 12 | profile 13 | *.moved-aside 14 | DerivedData 15 | .idea/ 16 | *.hmap 17 | *.xccheckout 18 | *.xcuserstate 19 | build/ 20 | 21 | ## Documentation 22 | docs/docsets/ 23 | docs/undocumented.json 24 | 25 | ## Gems 26 | .bundle 27 | vendor 28 | 29 | ## Carthage 30 | Carthage/Build 31 | 32 | ## CocoaPods 33 | Pods/ 34 | 35 | ## SwiftPM 36 | .swiftpm 37 | .build 38 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Common/Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UITableView { 4 | open override func touchesShouldCancel(in view: UIView) -> Bool { 5 | true 6 | } 7 | } 8 | 9 | extension UICollectionView { 10 | open override func touchesShouldCancel(in view: UIView) -> Bool { 11 | true 12 | } 13 | } 14 | 15 | extension UINavigationController { 16 | open override var supportedInterfaceOrientations: UIInterfaceOrientationMask { 17 | topViewController?.supportedInterfaceOrientations ?? .portrait 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Hello/HelloMessage.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | struct HelloMessage: IdentifiableComponent, Hashable { 5 | var name: String 6 | 7 | init(_ name: String) { 8 | self.name = name 9 | } 10 | 11 | func renderContent() -> HelloMessageContent { 12 | .loadFromNib() 13 | } 14 | 15 | func render(in content: HelloMessageContent) { 16 | content.label.text = "Hello \(name)" 17 | } 18 | } 19 | 20 | final class HelloMessageContent: UIView, NibLoadable { 21 | @IBOutlet var label: UILabel! 22 | } 23 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/realm/SwiftLint 2 | 3 | excluded: 4 | - Carthage 5 | - Pods 6 | - Examples/Example-iOS/Pods 7 | 8 | disabled_rules: 9 | - type_name 10 | - identifier_name 11 | - force_cast 12 | - xctfail_message 13 | 14 | nesting: 15 | type_level: 16 | warning: 2 17 | 18 | line_length: 19 | warning: 200 20 | 21 | file_length: 22 | warning: 600 23 | 24 | type_body_length: 25 | warning: 400 26 | 27 | function_body_length: 28 | warning: 60 29 | 30 | cyclomatic_complexity: 31 | warning: 12 32 | 33 | statement_position: 34 | statement_mode: uncuddled_else 35 | -------------------------------------------------------------------------------- /Sources/Interfaces/UICollectionComponentReusableView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// The header or footer view as the container that renders the component. 4 | open class UICollectionComponentReusableView: UICollectionReusableView, ComponentRenderable { 5 | @available(*, unavailable) 6 | public required init?(coder aDecoder: NSCoder) { 7 | fatalError("init(coder:) has not been implemented") 8 | } 9 | 10 | /// Create a new view with identifier for reuse. 11 | public override init(frame: CGRect) { 12 | super.init(frame: frame) 13 | 14 | backgroundColor = .clear 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Interfaces/UICollectionViewComponentCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// The cell as the container that renders the component. 4 | open class UICollectionViewComponentCell: UICollectionViewCell, ComponentRenderable { 5 | @available(*, unavailable) 6 | public required init?(coder aDecoder: NSCoder) { 7 | fatalError("init(coder:) has not been implemented") 8 | } 9 | 10 | /// Create a new cell with the frame. 11 | public override init(frame: CGRect) { 12 | super.init(frame: frame) 13 | 14 | backgroundColor = .clear 15 | contentView.backgroundColor = .clear 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/IdentifiableComponentTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Carbon 3 | 4 | final class IdentifiableComponentTests: XCTestCase { 5 | func testHashable() { 6 | let component = A.Component() 7 | 8 | XCTAssertEqual(component.id, component) 9 | XCTAssertEqual(component.id.hashValue, component.hashValue) 10 | } 11 | 12 | func testBuildCells() { 13 | let component = A.Component(value: 100) 14 | let cells = component.buildCells() 15 | 16 | XCTAssertEqual(cells.count, 1) 17 | XCTAssertEqual(cells[0].component(as: A.Component.self)?.value, 100) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Common/Footer.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | struct Footer: Component { 5 | var text: String 6 | 7 | init(_ text: String) { 8 | self.text = text 9 | } 10 | 11 | func renderContent() -> FooterContent { 12 | .loadFromNib() 13 | } 14 | 15 | func render(in content: FooterContent) { 16 | content.textLabel.text = text 17 | } 18 | 19 | func referenceSize(in bounds: CGRect) -> CGSize? { 20 | CGSize(width: bounds.width, height: 64) 21 | } 22 | } 23 | 24 | final class FooterContent: UIView, NibLoadable { 25 | @IBOutlet var textLabel: UILabel! 26 | } 27 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Common/Header.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Carbon 3 | 4 | struct Header: Component, View { 5 | var title: String 6 | 7 | init(_ title: String) { 8 | self.title = title 9 | } 10 | 11 | func renderContent() -> HeaderContent { 12 | .loadFromNib() 13 | } 14 | 15 | func render(in content: HeaderContent) { 16 | content.titleLabel.text = title 17 | } 18 | 19 | func referenceSize(in bounds: CGRect) -> CGSize? { 20 | CGSize(width: bounds.width, height: 64) 21 | } 22 | } 23 | 24 | final class HeaderContent: UIView, NibLoadable { 25 | @IBOutlet var titleLabel: UILabel! 26 | } 27 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Common/NibLoadable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol NibLoadable: class { 4 | static var nibName: String { get } 5 | static var nibBundle: Bundle? { get } 6 | } 7 | 8 | extension NibLoadable { 9 | static var nib: UINib { 10 | UINib(nibName: nibName, bundle: nibBundle) 11 | } 12 | 13 | static var nibName: String { 14 | String(describing: self) 15 | } 16 | 17 | static var nibBundle: Bundle? { 18 | Bundle(for: self) 19 | } 20 | } 21 | 22 | extension NibLoadable where Self: UIView { 23 | static func loadFromNib() -> Self { 24 | nib.instantiate(withOwner: nil, options: nil).first as! Self 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Pangram/PangramLabel.swift: -------------------------------------------------------------------------------- 1 | import Carbon 2 | import UIKit 3 | 4 | struct PangramLabel: IdentifiableComponent, Hashable { 5 | var text: String 6 | 7 | func renderContent() -> UILabel { 8 | let label = UILabel() 9 | label.textColor = .systemGreen 10 | label.textAlignment = .center 11 | label.font = .boldSystemFont(ofSize: 20) 12 | label.backgroundColor = UIColor.systemGray6 13 | return label 14 | } 15 | 16 | func render(in content: UILabel) { 17 | content.text = text 18 | } 19 | 20 | func referenceSize(in bounds: CGRect) -> CGSize? { 21 | CGSize(width: 30, height: 30) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Interfaces/UITableViewComponentHeaderFooterView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// The header or footer view as the container that renders the component. 4 | open class UITableViewComponentHeaderFooterView: UITableViewHeaderFooterView, ComponentRenderable { 5 | @available(*, unavailable) 6 | public required init?(coder aDecoder: NSCoder) { 7 | fatalError("init(coder:) has not been implemented") 8 | } 9 | 10 | /// Create a new view with identifier for reuse. 11 | public override init(reuseIdentifier: String?) { 12 | super.init(reuseIdentifier: reuseIdentifier) 13 | 14 | backgroundView = UIView() 15 | contentView.backgroundColor = .clear 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Create a feature request. 4 | --- 5 | 6 | ## Checklist 7 | - [ ] Reviewed the [README](https://github.com/ra1028/Carbon/blob/master/README.md) and [documents](https://ra1028.github.io/Carbon). 8 | - [ ] Searched [existing issues](https://github.com/ra1028/Carbon/issues) for ensure not duplicated. 9 | 10 | ## Description 11 | 12 | 13 | ## Motivation and Context 14 | 15 | 16 | 17 | ## Proposed Solution 18 | 19 | -------------------------------------------------------------------------------- /Sources/Interfaces/UITableViewComponentCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// The cell as the container that renders the component. 4 | open class UITableViewComponentCell: UITableViewCell, ComponentRenderable { 5 | @available(*, unavailable) 6 | public required init?(coder aDecoder: NSCoder) { 7 | fatalError("init(coder:) has not been implemented") 8 | } 9 | 10 | /// Create a new cell with style and identifier for reuse. 11 | public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 12 | super.init(style: style, reuseIdentifier: reuseIdentifier) 13 | 14 | backgroundColor = .clear 15 | contentView.backgroundColor = .clear 16 | selectionStyle = .none 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /XCConfigs/Carbon.xcconfig: -------------------------------------------------------------------------------- 1 | IPHONEOS_DEPLOYMENT_TARGET = 10.0 2 | 3 | SDKROOT = 4 | SUPPORTED_PLATFORMS = iphoneos iphonesimulator 5 | TARGETED_DEVICE_FAMILY = 1,2 6 | VALID_ARCHS[sdk=iphoneos*] = arm64 armv7 armv7s 7 | VALID_ARCHS[sdk=iphonesimulator*] = i386 x86_64 8 | OTHER_LDFLAGS = -weak_framework SwiftUI 9 | 10 | CODE_SIGNING_REQUIRED = NO 11 | CODE_SIGN_IDENTITY = 12 | CODE_SIGN_STYLE = Manual 13 | INSTALL_PATH = $(LOCAL_LIBRARY_DIR)/Frameworks 14 | SKIP_INSTALL = YES 15 | DYLIB_COMPATIBILITY_VERSION = 1 16 | DYLIB_CURRENT_VERSION = 1 17 | DYLIB_INSTALL_NAME_BASE = @rpath 18 | LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks @loader_path/Frameworks @loader_path/../Frameworks 19 | DEFINES_MODULE = NO 20 | BUILD_LIBRARY_FOR_DISTRIBUTION = YES 21 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Carbon", 7 | platforms: [ 8 | .iOS(.v10) 9 | ], 10 | products: [ 11 | .library(name: "Carbon", targets: ["Carbon"]) 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/ra1028/DifferenceKit.git", .upToNextMinor(from: "1.1.3")) 15 | ], 16 | targets: [ 17 | .target( 18 | name: "Carbon", 19 | dependencies: ["DifferenceKit"], 20 | path: "Sources" 21 | ), 22 | .testTarget( 23 | name: "Tests", 24 | dependencies: ["Carbon"], 25 | path: "Tests" 26 | ) 27 | ], 28 | swiftLanguageVersions: [.v5] 29 | ) 30 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Example-iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "MagazineLayout", 6 | "repositoryURL": "https://github.com/airbnb/MagazineLayout", 7 | "state": { 8 | "branch": null, 9 | "revision": "61fd38f770fb1b635fe486a6670381061424ff80", 10 | "version": null 11 | } 12 | }, 13 | { 14 | "package": "SwipeCellKit", 15 | "repositoryURL": "https://github.com/SwipeCellKit/SwipeCellKit", 16 | "state": { 17 | "branch": null, 18 | "revision": "7219cad3098637176ace311510d11467dbfb9bb0", 19 | "version": "2.7.1" 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/QUESTION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Create a bug question. 4 | --- 5 | 6 | ## Checklist 7 | - [ ] Reviewed the [README](https://github.com/ra1028/Carbon/blob/master/README.md) and [documents](https://ra1028.github.io/Carbon). 8 | - [ ] Searched [existing issues](https://github.com/ra1028/Carbon/issues) for ensure not duplicated. 9 | 10 | ## Expected Behavior 11 | 12 | 13 | ## Current Behavior 14 | 15 | 16 | ## Detailed Description (Include Screenshots) 17 | 18 | 19 | ## Environment 20 | - Carbon version: 21 | 22 | - Swift version: 23 | 24 | - iOS version: 25 | 26 | - Xcode version: 27 | 28 | - Devices/Simulators: 29 | 30 | - CocoaPods/Carthage version: 31 | -------------------------------------------------------------------------------- /Carbon.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = 'Carbon' 3 | spec.version = '1.0.0-rc.6' 4 | spec.author = { 'ra1028' => 'r.fe51028.r@gmail.com' } 5 | spec.homepage = 'https://github.com/ra1028/Carbon' 6 | spec.documentation_url = 'https://ra1028.github.io/Carbon' 7 | spec.summary = 'A declarative library for building component-based user interfaces in UITableView and UICollectionView.' 8 | spec.source = { :git => 'https://github.com/ra1028/Carbon.git', :tag => spec.version.to_s } 9 | spec.source_files = 'Sources/**/*.swift' 10 | spec.license = { :type => 'Apache 2.0', :file => 'LICENSE' } 11 | spec.requires_arc = true 12 | spec.swift_versions = ['5.1'] 13 | spec.ios.deployment_target = '10.0' 14 | spec.dependency 'DifferenceKit/Core', "~> 1.1" 15 | spec.ios.frameworks = 'UIKit' 16 | spec.ios.weak_frameworks = 'SwiftUI' 17 | end 18 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Form/FormTextView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | struct FormTextView: Component { 5 | var text: String? 6 | var onInput: (String?) -> Void 7 | 8 | func renderContent() -> FormTextViewContent { 9 | .loadFromNib() 10 | } 11 | 12 | func render(in content: FormTextViewContent) { 13 | if !content.textView.isFirstResponder { 14 | content.textView.text = text 15 | } 16 | content.onInput = onInput 17 | } 18 | } 19 | 20 | final class FormTextViewContent: UIView, NibLoadable, UITextViewDelegate { 21 | @IBOutlet var textView: UITextView! { 22 | didSet { 23 | textView.delegate = self 24 | } 25 | } 26 | 27 | var onInput: ((String?) -> Void)? 28 | 29 | func textViewDidChange(_ textView: UITextView) { 30 | onInput?(textView.text) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Kyoto/KyotoImage.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Carbon 3 | 4 | struct KyotoImage: IdentifiableComponent, View, Hashable { 5 | var title: String 6 | var image: UIImage 7 | 8 | func renderContent() -> KyotoImageContent { 9 | .loadFromNib() 10 | } 11 | 12 | func render(in content: KyotoImageContent) { 13 | content.imageView.image = image 14 | content.titleLabel.text = title 15 | } 16 | 17 | func referenceSize(in bounds: CGRect) -> CGSize? { 18 | CGSize(width: bounds.width, height: 150) 19 | } 20 | } 21 | 22 | final class KyotoImageContent: UIView, NibLoadable { 23 | @IBOutlet var imageView: UIImageView! 24 | @IBOutlet var titleLabel: UILabel! 25 | 26 | override func awakeFromNib() { 27 | super.awakeFromNib() 28 | 29 | layer.cornerRadius = 5 30 | layer.shadowColor = UIColor.black.cgColor 31 | layer.shadowOffset = CGSize(width: 0, height: 2) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Internal/RuntimeAssociation.swift: -------------------------------------------------------------------------------- 1 | import ObjectiveC 2 | 3 | internal final class RuntimeAssociation { 4 | private let key = UnsafeMutablePointer.allocate(capacity: 1) 5 | private let defaultValue: () -> Value 6 | 7 | deinit { 8 | key.deinitialize(count: 1) 9 | key.deallocate() 10 | } 11 | 12 | init(default defaultValue: @escaping @autoclosure () -> Value) { 13 | self.defaultValue = defaultValue 14 | } 15 | 16 | subscript(owner: Owner) -> Value { 17 | get { 18 | if let object = objc_getAssociatedObject(owner, key) as? Value { 19 | return object 20 | } 21 | else { 22 | let value = defaultValue() 23 | self[owner] = value 24 | return value 25 | } 26 | } 27 | set { 28 | objc_setAssociatedObject(owner, key, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Updaters/Updater.swift: -------------------------------------------------------------------------------- 1 | /// Represents an updater that manages the updation for target. 2 | public protocol Updater { 3 | /// A type that represents a target to be updated for render given data. 4 | associatedtype Target: AnyObject 5 | 6 | /// A type that represents an adapter holding the data to be rendered. 7 | associatedtype Adapter: Carbon.Adapter 8 | 9 | /// Prepares given target and adapter. 10 | /// 11 | /// - Parameters: 12 | /// - target: A target to be prepared. 13 | /// - adapter: An adapter to be prepared. 14 | func prepare(target: Target, adapter: Adapter) 15 | 16 | /// Perform updates to render given data to the target. 17 | /// 18 | /// - Parameters: 19 | /// - target: A target instance to be updated to render given data. 20 | /// - adapter: An adapter holding currently rendered data. 21 | /// - data: A collection of sections to be rendered next. 22 | func performUpdates(target: Target, adapter: Adapter, data: [Section]) 23 | } 24 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Form/FormSwitch.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | struct FormSwitch: IdentifiableComponent { 5 | var title: String 6 | var isOn: Bool 7 | var onSwitch: (Bool) -> Void 8 | 9 | var id: String { 10 | title 11 | } 12 | 13 | func renderContent() -> FormSwitchContent { 14 | .loadFromNib() 15 | } 16 | 17 | func render(in content: FormSwitchContent) { 18 | content.titleLabel.text = title 19 | content.switch.isOn = isOn 20 | content.onSwitch = onSwitch 21 | } 22 | } 23 | 24 | final class FormSwitchContent: UIView, NibLoadable { 25 | @IBOutlet var titleLabel: UILabel! 26 | @IBOutlet var `switch`: UISwitch! 27 | 28 | var onSwitch: ((Bool) -> Void)? 29 | 30 | override func awakeFromNib() { 31 | super.awakeFromNib() 32 | 33 | self.switch.addTarget(self, action: #selector(switched), for: .valueChanged) 34 | } 35 | 36 | @objc func switched() { 37 | onSwitch?(self.switch.isOn) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/Nodes/ViewNodeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Carbon 3 | 4 | final class ViewNodeTests: XCTestCase { 5 | func testInitWithComponent() { 6 | let component = A.Component() 7 | let node = ViewNode(component) 8 | 9 | XCTAssertEqual(node.component.as(A.Component.self), component) 10 | } 11 | 12 | func testComponentCasting() { 13 | let component = A.Component() 14 | let node = ViewNode(component) 15 | 16 | XCTAssertNil(node.component(as: Never.self)) 17 | XCTAssertNotNil(node.component(as: A.Component.self)) 18 | } 19 | 20 | func testContentEquatableConformance() { 21 | let component1 = MockComponent(shouldContentUpdate: true) 22 | let component2 = MockComponent(shouldContentUpdate: false) 23 | let node1 = ViewNode(component1) 24 | let node2 = ViewNode(component2) 25 | 26 | XCTAssertEqual(node1.isContentEqual(to: node1), false) 27 | XCTAssertEqual(node2.isContentEqual(to: node2), true) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Kyoto/KyotoLicense.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Carbon 3 | 4 | struct KyotoLicense: Component, View { 5 | var onSelected: () -> Void 6 | 7 | func renderContent() -> KyotoLicenseContent { 8 | .loadFromNib() 9 | } 10 | 11 | func render(in content: KyotoLicenseContent) { 12 | content.onSelected = onSelected 13 | } 14 | 15 | func referenceSize(in bounds: CGRect) -> CGSize? { 16 | CGSize(width: bounds.size.width, height: 71) 17 | } 18 | } 19 | 20 | final class KyotoLicenseContent: UIControl, NibLoadable { 21 | override var isHighlighted: Bool { 22 | didSet { 23 | backgroundColor = isHighlighted ? .systemGray4 : .clear 24 | } 25 | } 26 | 27 | var onSelected: (() -> Void)? 28 | 29 | override func awakeFromNib() { 30 | super.awakeFromNib() 31 | 32 | addTarget(self, action: #selector(selected), for: .touchUpInside) 33 | } 34 | 35 | @objc func selected() { 36 | onSelected?() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/ComponentWrapper/IdentifiedComponentWrapperTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Carbon 3 | 4 | final class IdentifiedComponentWrapperTests: XCTestCase { 5 | func testIDAndWrapped() { 6 | let id = 1000 7 | let component = A.Component() 8 | let wrapper = IdentifiedComponentWrapper(id: id, wrapped: component) 9 | 10 | XCTAssertEqual(wrapper.id, id) 11 | XCTAssertEqual(wrapper.wrapped, component) 12 | } 13 | 14 | func testIdentifiedOperatorWithSpecifyingID() { 15 | let id = 1000 16 | let component = A.Component() 17 | let wrapper = component.identified(by: id) 18 | 19 | XCTAssertEqual(wrapper.id, id) 20 | XCTAssertEqual(wrapper.wrapped, component) 21 | } 22 | 23 | func testIdentifiedOperatorWithSpecifyingKeyPath() { 24 | let component = A.Component() 25 | let wrapper = component.identified(by: \.value) 26 | 27 | XCTAssertEqual(wrapper.id, component.value) 28 | XCTAssertEqual(wrapper.wrapped, component) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Form/FormDatePicker.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | struct FormDatePicker: Component { 5 | var date: Date 6 | var onSelect: (Date) -> Void 7 | 8 | func renderContent() -> FormDatePickerContent { 9 | .loadFromNib() 10 | } 11 | 12 | func render(in content: FormDatePickerContent) { 13 | content.datePicker.date = date 14 | content.onSelect = onSelect 15 | } 16 | 17 | func shouldRender(next: FormDatePicker, in content: FormDatePickerContent) -> Bool { 18 | false 19 | } 20 | } 21 | 22 | final class FormDatePickerContent: UIView, NibLoadable { 23 | @IBOutlet var datePicker: UIDatePicker! 24 | 25 | var onSelect: ((Date) -> Void)? 26 | 27 | override func awakeFromNib() { 28 | super.awakeFromNib() 29 | 30 | datePicker.setValue(UIColor.label, forKeyPath: "textColor") 31 | datePicker.addTarget(self, action: #selector(selected), for: .valueChanged) 32 | } 33 | 34 | @objc func selected() { 35 | onSelect?(datePicker.date) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/Updater/UITableViewReloadDataUpdaterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import DifferenceKit 3 | @testable import Carbon 4 | 5 | final class UITableViewReloadDataUpdaterTests: XCTestCase { 6 | func testPrepare() { 7 | let updater = MockTableViewReloadDataUpdater() 8 | let adapter = MockTableViewAdapter() 9 | let tableView = MockTableView() 10 | updater.prepare(target: tableView, adapter: adapter) 11 | 12 | XCTAssertTrue(tableView.isReloadDataCalled) 13 | XCTAssertEqual(tableView.delegate as? MockTableViewAdapter, adapter) 14 | XCTAssertEqual(tableView.dataSource as? MockTableViewAdapter, adapter) 15 | } 16 | 17 | func testPerformUpdates() { 18 | let updater = MockTableViewReloadDataUpdater() 19 | let adapter = MockTableViewAdapter() 20 | let tableView = MockTableView() 21 | 22 | updater.performUpdates(target: tableView, adapter: adapter, data: [Section(id: TestID.a)]) 23 | XCTAssertEqual(adapter.data.count, 1) 24 | XCTAssertTrue(tableView.isReloadDataCalled) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Checklist 2 | - [ ] All tests are passed. 3 | - [ ] Added tests. 4 | - [ ] Documented the code using [Xcode markup](https://developer.apple.com/library/mac/documentation/Xcode/Reference/xcode_markup_formatting_ref). 5 | - [ ] Searched [existing pull requests](https://github.com/ra1028/Carbon/pulls) for ensure not duplicated. 6 | 7 | ## Description 8 | 9 | 10 | ## Related Issue 11 | 12 | 13 | 14 | 15 | 16 | ## Motivation and Context 17 | 18 | 19 | 20 | ## Impact on Existing Code 21 | 22 | 23 | ## Screenshots (if appropriate) 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Actions 2 | on: push 3 | jobs: 4 | macOS: 5 | name: Test on macOS 6 | runs-on: macOS-10.14 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | xcode_version: 11 | - 11.1 12 | destination: 13 | - platform=iOS Simulator,name=iPhone 11 Pro,OS=13.1 14 | # GitHub actions is now unsupported iOS10 simulator. 15 | # - platform=iOS Simulator,name=iPhone SE,OS=10.0 16 | env: 17 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode_version }}.app 18 | steps: 19 | - uses: actions/checkout@v1 20 | with: 21 | submodules: true 22 | - name: Show environments 23 | run: | 24 | swift --version 25 | xcodebuild -version 26 | - name: Test iOS on Xcode ${{ matrix.xcode_version }} simulator ${{ matrix.destination }} 27 | run: | 28 | set -o pipefail && xcodebuild build-for-testing test-without-building -workspace Carbon.xcworkspace -scheme Carbon -configuration Release -sdk iphonesimulator -destination '${{ matrix.destination }}' ENABLE_TESTABILITY=YES | xcpretty -c 29 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | final class AppDelegate: UIResponder, UIApplicationDelegate { 5 | var window: UIWindow? 6 | 7 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 8 | configureUIAppearance() 9 | 10 | let window = UIWindow() 11 | window.rootViewController = UINavigationController(rootViewController: HomeViewController()) 12 | window.makeKeyAndVisible() 13 | self.window = window 14 | 15 | return true 16 | } 17 | 18 | func configureUIAppearance() { 19 | let appearance = UINavigationBar.appearance() 20 | let titleTextAttributes: [NSAttributedString.Key: Any] = [ 21 | .foregroundColor: UIColor.label 22 | ] 23 | 24 | appearance.tintColor = .label 25 | appearance.prefersLargeTitles = true 26 | appearance.isTranslucent = true 27 | appearance.titleTextAttributes = titleTextAttributes 28 | appearance.largeTitleTextAttributes = titleTextAttributes 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Form/FormLabel.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | struct FormLabel: IdentifiableComponent { 5 | var title: String 6 | var text: String? 7 | var onSelect: () -> Void 8 | 9 | var id: String { 10 | title 11 | } 12 | 13 | func renderContent() -> FormLabelContent { 14 | .loadFromNib() 15 | } 16 | 17 | func render(in content: FormLabelContent) { 18 | content.titleLabel.text = title 19 | content.textLabel.text = text 20 | content.onSelect = onSelect 21 | } 22 | } 23 | 24 | final class FormLabelContent: UIControl, NibLoadable { 25 | @IBOutlet var titleLabel: UILabel! 26 | @IBOutlet var textLabel: UILabel! 27 | 28 | var onSelect: (() -> Void)? 29 | 30 | override var isHighlighted: Bool { 31 | didSet { 32 | backgroundColor = isHighlighted ? .systemGray4 : .secondarySystemBackground 33 | } 34 | } 35 | 36 | override func awakeFromNib() { 37 | super.awakeFromNib() 38 | 39 | addTarget(self, action: #selector(selected), for: .touchUpInside) 40 | } 41 | 42 | @objc func selected() { 43 | onSelect?() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/Updater/UICollectionViewReloadDataTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import DifferenceKit 3 | @testable import Carbon 4 | 5 | final class UICollectionViewReloadDataTests: XCTestCase { 6 | func testPrepare() { 7 | let updater = MockCollectionViewReloadDataUpdater() 8 | let adapter = MockCollectionViewFlowLayoutAdapter() 9 | let collectionView = MockCollectionView() 10 | updater.prepare(target: collectionView, adapter: adapter) 11 | 12 | XCTAssertTrue(collectionView.isReloadDataCalled) 13 | XCTAssertEqual(collectionView.delegate as? MockCollectionViewFlowLayoutAdapter, adapter) 14 | XCTAssertEqual(collectionView.dataSource as? MockCollectionViewFlowLayoutAdapter, adapter) 15 | } 16 | 17 | func testPerformUpdates() { 18 | let updater = MockCollectionViewReloadDataUpdater() 19 | let adapter = MockCollectionViewFlowLayoutAdapter() 20 | let collectionView = MockCollectionView() 21 | 22 | updater.performUpdates(target: collectionView, adapter: adapter, data: [Section(id: TestID.a)]) 23 | XCTAssertEqual(adapter.data.count, 1) 24 | XCTAssertTrue(collectionView.isReloadDataCalled) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /docs/badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | documentation 17 | 18 | 19 | documentation 20 | 21 | 22 | 100% 23 | 24 | 25 | 100% 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Emoji/EmojiLabel.swift: -------------------------------------------------------------------------------- 1 | import Carbon 2 | import UIKit 3 | 4 | struct EmojiLabel: IdentifiableComponent { 5 | var code: Int 6 | var onSelect: () -> Void 7 | 8 | var id: Int { 9 | code 10 | } 11 | 12 | func renderContent() -> EmojiLabelContent { 13 | .loadFromNib() 14 | } 15 | 16 | func render(in content: EmojiLabelContent) { 17 | content.label.text = UnicodeScalar(code).map(String.init) 18 | content.onSelect = onSelect 19 | } 20 | 21 | func referenceSize(in bounds: CGRect) -> CGSize? { 22 | CGSize(width: 50, height: 30) 23 | } 24 | } 25 | 26 | final class EmojiLabelContent: UIControl, NibLoadable { 27 | @IBOutlet var label: UILabel! 28 | 29 | var onSelect: (() -> Void)? 30 | 31 | override var isHighlighted: Bool { 32 | didSet { 33 | alpha = isHighlighted ? 0.5 : 1 34 | } 35 | } 36 | 37 | override func awakeFromNib() { 38 | super.awakeFromNib() 39 | 40 | layer.cornerRadius = 8 41 | addTarget(self, action: #selector(selected), for: .touchUpInside) 42 | } 43 | 44 | @objc func selected() { 45 | onSelect?() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a bug report. 4 | --- 5 | 6 | ## Checklist 7 | - [ ] This is not a Apple's bug. 8 | - [ ] Reviewed the [README](https://github.com/ra1028/Carbon/blob/master/README.md) and [documents](https://ra1028.github.io/Carbon). 9 | - [ ] Searched [existing issues](https://github.com/ra1028/Carbon/issues) for ensure not duplicated. 10 | 11 | ## Expected Behavior 12 | 13 | 14 | ## Current Behavior 15 | 16 | 17 | ## Steps to Reproduce 18 | 19 | 20 | 1. 21 | 2. 22 | 3. 23 | 4. 24 | 25 | ## Detailed Description (Include Screenshots) 26 | 27 | 28 | ## Reproducible Demo Project 29 | 30 | 31 | ## Environments 32 | - Carbon version: 33 | 34 | - Swift version: 35 | 36 | - iOS version: 37 | 38 | - Xcode version: 39 | 40 | - Devices/Simulators: 41 | 42 | - CocoaPods/Carthage version: 43 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Top/HomeItem.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | struct HomeItem: IdentifiableComponent { 5 | var title: String 6 | var onSelect: () -> Void 7 | 8 | var id: String { 9 | title 10 | } 11 | 12 | func renderContent() -> HomeItemContent { 13 | .loadFromNib() 14 | } 15 | 16 | func render(in content: HomeItemContent) { 17 | content.titleLabel.text = title 18 | content.onSelect = onSelect 19 | } 20 | } 21 | 22 | final class HomeItemContent: UIControl, NibLoadable { 23 | @IBOutlet var titleLabel: UILabel! 24 | @IBOutlet var indicatorImageView: UIImageView! 25 | 26 | var onSelect: (() -> Void)? 27 | 28 | override var isHighlighted: Bool { 29 | didSet { 30 | backgroundColor = isHighlighted ? .systemGray4 : .clear 31 | } 32 | } 33 | 34 | override func awakeFromNib() { 35 | super.awakeFromNib() 36 | 37 | indicatorImageView.image = indicatorImageView.image?.withRenderingMode(.alwaysTemplate) 38 | addTarget(self, action: #selector(selected), for: .touchUpInside) 39 | } 40 | 41 | @objc private func selected() { 42 | onSelect?() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Updaters/UITableViewReloadDataUpdater.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// An updater for managing to perform reload data to render data to the `UITableView`. 4 | open class UITableViewReloadDataUpdater: Updater { 5 | /// Create a new updater. 6 | public init() {} 7 | 8 | /// Set the `delegate` and `dataSource` of given table view, then reload data. 9 | /// 10 | /// - Parameters: 11 | /// - target: A target to be prepared. 12 | /// - adapter: An adapter to be set to `delegate` and `dataSource`. 13 | open func prepare(target: UITableView, adapter: Adapter) { 14 | target.delegate = adapter 15 | target.dataSource = adapter 16 | target.reloadData() 17 | } 18 | 19 | /// Perform reload data to render given data to the target. 20 | /// 21 | /// - Parameters: 22 | /// - target: A target instance to be updated to render given data. 23 | /// - adapter: An adapter holding currently rendered data. 24 | /// - data: A collection of sections to be rendered next. 25 | open func performUpdates(target: UITableView, adapter: Adapter, data: [Section]) { 26 | adapter.data = data 27 | target.reloadData() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Emoji/EmojiViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | final class EmojiViewController: UIViewController { 5 | @IBOutlet var collectionView: UICollectionView! 6 | @IBOutlet var toolBar: UIToolbar! 7 | 8 | private let renderer = Renderer( 9 | adapter: UICollectionViewFlowLayoutAdapter(), 10 | updater: UICollectionViewUpdater() 11 | ) 12 | 13 | var emojiCodes: [Int] = [] { 14 | didSet { render() } 15 | } 16 | 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | 20 | title = "Shuffle Emoji" 21 | collectionView.contentInset.top = 44 22 | 23 | renderer.target = collectionView 24 | 25 | refresh() 26 | } 27 | 28 | func render() { 29 | renderer.render { 30 | Group(of: emojiCodes.enumerated()) { offset, code in 31 | EmojiLabel(code: code) { [weak self] in 32 | self?.emojiCodes.remove(at: offset) 33 | } 34 | } 35 | } 36 | } 37 | 38 | @IBAction func refresh() { 39 | emojiCodes = Array(0x1F600...0x1F647) 40 | } 41 | 42 | @IBAction func shuffle() { 43 | emojiCodes.shuffle() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/IdentifiableComponent.swift: -------------------------------------------------------------------------------- 1 | /// Represents a component that can be uniquely identify. 2 | /// 3 | /// Example for the simple identified component: 4 | /// 5 | /// struct UserLabel: IdentifiableComponent { 6 | /// var id: Int64 7 | /// var name: String 8 | /// 9 | /// func renderContent() -> UILabel { 10 | /// return UILabel() 11 | /// } 12 | /// 13 | /// func render(in content: UILabel) { 14 | /// content.text = name 15 | /// } 16 | /// } 17 | public protocol IdentifiableComponent: Component, CellsBuildable { 18 | /// A type that represents an id that used to uniquely identify the component. 19 | associatedtype ID: Hashable 20 | 21 | /// An identifier that used to uniquely identify the component. 22 | var id: ID { get } 23 | } 24 | 25 | public extension IdentifiableComponent { 26 | /// Build an array of section. 27 | func buildCells() -> [CellNode] { 28 | return [CellNode(self)] 29 | } 30 | } 31 | 32 | public extension IdentifiableComponent where Self: Hashable { 33 | /// An identifier that can be used to uniquely identify the component. 34 | /// Default is `self`. 35 | var id: Self { 36 | return self 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Updaters/UICollectionViewReloadDataUpdater.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// An updater for managing to perform reload data to render data to the `UICollectionView`. 4 | open class UICollectionViewReloadDataUpdater: Updater { 5 | /// Create a new updater. 6 | public init() {} 7 | 8 | /// Set the `delegate` and `dataSource` of given collection view, then reload data and invalidate layout. 9 | /// 10 | /// - Parameters: 11 | /// - target: A target to be prepared. 12 | /// - adapter: An adapter to be set to `delegate` and `dataSource`. 13 | open func prepare(target: UICollectionView, adapter: Adapter) { 14 | target.delegate = adapter 15 | target.dataSource = adapter 16 | target.reloadData() 17 | target.collectionViewLayout.invalidateLayout() 18 | } 19 | 20 | /// Perform reload data to render given data to the target. 21 | /// 22 | /// - Parameters: 23 | /// - target: A target instance to be updated to render given data. 24 | /// - adapter: An adapter holding currently rendered data. 25 | /// - data: A collection of sections to be rendered next. 26 | open func performUpdates(target: UICollectionView, adapter: Adapter, data: [Section]) { 27 | adapter.data = data 28 | target.reloadData() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/FunctionBuilder/SectionsBuildableTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Carbon 3 | 4 | final class SectionsBuildableTests: XCTestCase { 5 | func testBuildSections() { 6 | let section0 = Section(id: 0) 7 | let section1 = Section(id: 1) 8 | let s = MockSectionsBuildable( 9 | sections: [ 10 | section0, 11 | section1 12 | ] 13 | ) 14 | let sections = s.buildSections() 15 | 16 | XCTAssertEqual(sections.count, 2) 17 | XCTAssertEqual(sections[0].id, 0) 18 | XCTAssertEqual(sections[1].id, 1) 19 | } 20 | 21 | func testBuildSectionsFromSome() { 22 | let section0 = Section(id: 0) 23 | let section1 = Section(id: 1) 24 | let s: MockSectionsBuildable? = MockSectionsBuildable( 25 | sections: [ 26 | section0, 27 | section1 28 | ] 29 | ) 30 | let sections = s.buildSections() 31 | 32 | XCTAssertEqual(sections.count, 2) 33 | XCTAssertEqual(sections[0].id, 0) 34 | XCTAssertEqual(sections[1].id, 1) 35 | } 36 | 37 | func testBuildSectionsFromNone() { 38 | let s: MockSectionsBuildable? = nil 39 | let sections = s.buildSections() 40 | 41 | XCTAssertTrue(sections.isEmpty) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Pangram/PangramViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | final class PangramViewController: UIViewController { 5 | @IBOutlet var collectionView: UICollectionView! 6 | @IBOutlet var toolBar: UIToolbar! 7 | 8 | private let renderer = Renderer( 9 | adapter: UICollectionViewFlowLayoutAdapter(), 10 | updater: UICollectionViewUpdater() 11 | ) 12 | 13 | var isSorted = true { 14 | didSet { render() } 15 | } 16 | 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | 20 | title = "Pangram" 21 | collectionView.contentInset.top = 44 22 | renderer.target = collectionView 23 | 24 | render() 25 | } 26 | 27 | func render() { 28 | let pangram = isSorted 29 | ? ["ABC", "DEF", "GHI", "JKL", "MNO", "PQR", "STU", "VWY", "XZ"] 30 | : ["THE", "QUICK", "BROWN", "FOX", "JUMPS", "OVER", "THE", "LAZY", "DOG"] 31 | 32 | renderer.render { 33 | Group(of: pangram.enumerated()) { offset, word in 34 | Section(id: offset) { 35 | Group(of: word) { text in 36 | PangramLabel(text: String(text)) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | @IBAction func toggle() { 44 | isSorted.toggle() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Hello/HelloViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | final class HelloViewController: UIViewController { 5 | @IBOutlet var tableView: UITableView! 6 | 7 | var isToggled = false { 8 | didSet { render() } 9 | } 10 | 11 | private let renderer = Renderer( 12 | adapter: UITableViewAdapter(), 13 | updater: UITableViewUpdater() 14 | ) 15 | 16 | override func viewDidLoad() { 17 | super.viewDidLoad() 18 | 19 | title = "Hello" 20 | tableView.contentInset.top = 44 21 | renderer.target = tableView 22 | 23 | render() 24 | } 25 | 26 | func render() { 27 | renderer.render { 28 | Header("GREET") 29 | .identified(by: \.title) 30 | 31 | if isToggled { 32 | HelloMessage("Jules") 33 | HelloMessage("Vincent") 34 | } 35 | else { 36 | HelloMessage("Vincent") 37 | HelloMessage("Jules") 38 | HelloMessage("Mia") 39 | } 40 | 41 | Footer("👋 Greeting from Carbon") 42 | .identified(by: \.text) 43 | } 44 | } 45 | 46 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 47 | super.touchesEnded(touches, with: event) 48 | 49 | isToggled.toggle() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Internal/UIScrollViewExtensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | internal extension UIScrollView { 4 | var _isScrolling: Bool { 5 | return isTracking || isDragging || isDecelerating 6 | } 7 | 8 | func _setAdjustedContentOffsetIfNeeded(_ contentOffset: CGPoint) { 9 | let maxContentOffsetX = contentSize.width + availableContentInset.right - bounds.width 10 | let maxContentOffsetY = contentSize.height + availableContentInset.bottom - bounds.height 11 | let isContentRectContainsBounds = CGRect(origin: .zero, size: contentSize) 12 | .inset(by: availableContentInset.inverted) 13 | .contains(bounds) 14 | 15 | if isContentRectContainsBounds && !_isScrolling { 16 | self.contentOffset = CGPoint( 17 | x: min(maxContentOffsetX, contentOffset.x), 18 | y: min(maxContentOffsetY, contentOffset.y) 19 | ) 20 | } 21 | } 22 | } 23 | 24 | private extension UIScrollView { 25 | var availableContentInset: UIEdgeInsets { 26 | if #available(iOS 11.0, tvOS 11.0, *) { 27 | return adjustedContentInset 28 | } 29 | else { 30 | return contentInset 31 | } 32 | } 33 | } 34 | 35 | private extension UIEdgeInsets { 36 | var inverted: UIEdgeInsets { 37 | return UIEdgeInsets(top: -top, left: -left, bottom: -bottom, right: -right) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Todo/TodoSwipeCellKitAdapter.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | import SwipeCellKit 4 | 5 | extension SwipeTableViewCell: ComponentRenderable {} 6 | 7 | final class TodoSwipeCellKitAdapter: UITableViewAdapter, SwipeTableViewCellDelegate { 8 | override func cellRegistration(tableView: UITableView, indexPath: IndexPath, node: CellNode) -> CellRegistration { 9 | CellRegistration(class: SwipeTableViewCell.self) 10 | } 11 | 12 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 13 | let cell = super.tableView(tableView, cellForRowAt: indexPath) as! SwipeTableViewCell 14 | cell.delegate = self 15 | cell.selectionStyle = .none 16 | cell.backgroundColor = .clear 17 | return cell 18 | } 19 | 20 | func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath, for orientation: SwipeActionsOrientation) -> [SwipeAction]? { 21 | guard let deletable = cellNode(at: indexPath).component(as: Deletable.self), orientation == .right else { 22 | return nil 23 | } 24 | 25 | let deleteAction = SwipeAction(style: .destructive, title: nil) { action, _ in 26 | deletable.delete() 27 | action.fulfill(with: .delete) 28 | } 29 | 30 | deleteAction.image = #imageLiteral(resourceName: "Trash") 31 | return [deleteAction] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/FunctionBuilder/CellsBuildableTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Carbon 3 | 4 | final class CellsBuildableTests: XCTestCase { 5 | func testBuildCells() { 6 | let componentA = A.Component() 7 | let componentB = B.Component() 8 | let c = MockCellsBuildable( 9 | cells: [ 10 | CellNode(componentA), 11 | CellNode(componentB) 12 | ] 13 | ) 14 | let cells = c.buildCells() 15 | 16 | XCTAssertEqual(cells.count, 2) 17 | XCTAssertEqual(cells[0].component(as: A.Component.self), componentA) 18 | XCTAssertEqual(cells[1].component(as: B.Component.self), componentB) 19 | } 20 | 21 | func testBuildCellsFromSome() { 22 | let componentA = A.Component() 23 | let componentB = B.Component() 24 | let c: MockCellsBuildable? = MockCellsBuildable( 25 | cells: [ 26 | CellNode(componentA), 27 | CellNode(componentB) 28 | ] 29 | ) 30 | let cells = c.buildCells() 31 | 32 | XCTAssertEqual(cells.count, 2) 33 | XCTAssertEqual(cells[0].component(as: A.Component.self), componentA) 34 | XCTAssertEqual(cells[1].component(as: B.Component.self), componentB) 35 | } 36 | 37 | func testBuildCellsFromNone() { 38 | let c: MockCellsBuildable? = nil 39 | let cells = c.buildCells() 40 | 41 | XCTAssertTrue(cells.isEmpty) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Todo/TodoText.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | struct TodoText: IdentifiableComponent, Deletable { 5 | enum Event { 6 | case toggleCompleted 7 | case delete 8 | } 9 | 10 | var todo: Todo 11 | var isCompleted: Bool 12 | var onEvent: (Event) -> Void 13 | 14 | var id: Todo.ID { 15 | todo.id 16 | } 17 | 18 | func renderContent() -> TodoTextContent { 19 | .loadFromNib() 20 | } 21 | 22 | func render(in content: TodoTextContent) { 23 | let attributes = isCompleted ? [NSAttributedString.Key.strikethroughStyle: NSUnderlineStyle.single.rawValue] : [:] 24 | content.textLabel.attributedText = NSAttributedString(string: todo.text, attributes: attributes) 25 | content.completeButton.isSelected = isCompleted 26 | content.onToggleCompleted = { 27 | self.onEvent(.toggleCompleted) 28 | } 29 | } 30 | 31 | func delete() { 32 | onEvent(.delete) 33 | } 34 | } 35 | 36 | final class TodoTextContent: UIView, NibLoadable { 37 | @IBOutlet var textLabel: UILabel! 38 | @IBOutlet var completeButton: UIButton! 39 | 40 | var onToggleCompleted: (() -> Void)? 41 | 42 | override func awakeFromNib() { 43 | super.awakeFromNib() 44 | 45 | completeButton.addTarget(self, action: #selector(toggleCompleted), for: .touchUpInside) 46 | } 47 | 48 | @objc func toggleCompleted() { 49 | onToggleCompleted?() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Nodes/ViewNode.swift: -------------------------------------------------------------------------------- 1 | import DifferenceKit 2 | 3 | /// The node for view which need not be uniquely identified like header or footer. 4 | /// Erase the type of component and wrapping it. 5 | /// This works as an intermediary for `DifferenceKit`. 6 | public struct ViewNode { 7 | /// A type-erased component which wrapped in `self`. 8 | public var component: AnyComponent 9 | 10 | /// Create a node wrapping given component. 11 | /// 12 | /// - Parameter 13 | /// - component: A component to be wrap. 14 | public init(_ component: C) { 15 | self.component = AnyComponent(component) 16 | } 17 | 18 | /// Returns a base instance of component casted as given type if possible. 19 | /// 20 | /// - Parameter: An expected type of the base instance of component to casted. 21 | /// - Returns: A casted base instance. 22 | public func component(as _: T.Type) -> T? { 23 | return component.as(T.self) 24 | } 25 | } 26 | 27 | extension ViewNode: ContentEquatable { 28 | /// Indicate whether the content of `self` is equals to the content of 29 | /// the given source value. 30 | @inlinable 31 | public func isContentEqual(to source: ViewNode) -> Bool { 32 | return !source.component.shouldContentUpdate(with: component) 33 | } 34 | } 35 | 36 | extension ViewNode: CustomDebugStringConvertible { 37 | /// A textual representation of this instance, suitable for debugging. 38 | @inlinable 39 | public var debugDescription: String { 40 | return "ViewNode(component: \(component))" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/Adapters/MockCustomXibCollectionViewReusableView.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/KyotoSwiftUI/KyotoSwiftUIView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct KyotoSwiftUIView: View { 4 | var body: some View { 5 | ScrollView { 6 | VStack { 7 | KyotoTop() 8 | .frame(height: 435) 9 | 10 | Header("PHOTOS") 11 | 12 | VStack { 13 | HStack { 14 | KyotoImage(title: "Fushimi Inari-taisha", image: #imageLiteral(resourceName: "KyotoFushimiInari")) 15 | KyotoImage(title: "Arashiyama", image: #imageLiteral(resourceName: "KyotoArashiyama")) 16 | } 17 | 18 | HStack { 19 | KyotoImage(title: "Byōdō-in", image: #imageLiteral(resourceName: "KyotoByōdōIn")) 20 | KyotoImage(title: "Gion", image: #imageLiteral(resourceName: "KyotoGion")) 21 | } 22 | 23 | HStack { 24 | KyotoImage(title: "Kiyomizu-dera", image: #imageLiteral(resourceName: "KyotoKiyomizuDera")) 25 | Color.clear 26 | } 27 | } 28 | .padding(.horizontal, 16) 29 | 30 | KyotoLicense { 31 | let url = URL(string: "https://unsplash.com/")! 32 | UIApplication.shared.open(url) 33 | } 34 | } 35 | } 36 | .navigationBarTitle("Kyoto") 37 | } 38 | } 39 | 40 | struct KyotoSwiftUIView_Previews: PreviewProvider { 41 | static var previews: some View { 42 | KyotoSwiftUIView() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIRequiredDeviceCapabilities 26 | 27 | armv7 28 | 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UIViewControllerBasedStatusBarAppearance 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Form/FormTextField.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | struct FormTextField: IdentifiableComponent { 5 | var title: String 6 | var text: String? 7 | var keyboardType: UIKeyboardType 8 | var onInput: (String?) -> Void 9 | 10 | init(title: String, text: String?, keyboardType: UIKeyboardType = .default, onInput: @escaping (String?) -> Void) { 11 | self.title = title 12 | self.text = text 13 | self.keyboardType = keyboardType 14 | self.onInput = onInput 15 | } 16 | 17 | var id: String { 18 | title 19 | } 20 | 21 | func renderContent() -> FormTextFieldContent { 22 | .loadFromNib() 23 | } 24 | 25 | func render(in content: FormTextFieldContent) { 26 | if !content.textField.isFirstResponder { 27 | content.textField.text = text 28 | } 29 | 30 | content.titleLabel.text = title 31 | content.textField.keyboardType = keyboardType 32 | content.onInput = onInput 33 | } 34 | } 35 | 36 | final class FormTextFieldContent: UIControl, NibLoadable { 37 | @IBOutlet var titleLabel: UILabel! 38 | @IBOutlet var textField: UITextField! 39 | 40 | var onInput: ((String?) -> Void)? 41 | 42 | override func awakeFromNib() { 43 | super.awakeFromNib() 44 | 45 | addTarget(self, action: #selector(selected), for: .touchUpInside) 46 | textField.addTarget(self, action: #selector(input), for: .allEditingEvents) 47 | } 48 | 49 | @objc func selected() { 50 | textField.becomeFirstResponder() 51 | } 52 | 53 | @objc func input() { 54 | onInput?(textField.text) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Form/FormTextPicker.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | 4 | struct FormTextPicker: Component { 5 | var texts: [String] 6 | var onSelect: (String) -> Void 7 | 8 | func renderContent() -> FormTextPickerContent { 9 | .loadFromNib() 10 | } 11 | 12 | func render(in content: FormTextPickerContent) { 13 | content.texts = texts 14 | content.onSelect = onSelect 15 | } 16 | 17 | func shouldRender(next: FormTextPicker, in content: FormTextPickerContent) -> Bool { 18 | texts != next.texts 19 | } 20 | } 21 | 22 | final class FormTextPickerContent: UIView, NibLoadable, UIPickerViewDelegate, UIPickerViewDataSource { 23 | @IBOutlet var pickerView: UIPickerView! { 24 | didSet { 25 | pickerView.delegate = self 26 | pickerView.dataSource = self 27 | } 28 | } 29 | 30 | var texts = [String]() { 31 | didSet { 32 | pickerView.reloadAllComponents() 33 | } 34 | } 35 | var onSelect: ((String) -> Void)? 36 | 37 | func numberOfComponents(in pickerView: UIPickerView) -> Int { 38 | 1 39 | } 40 | 41 | func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { 42 | texts.count 43 | } 44 | 45 | func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? { 46 | NSAttributedString(string: texts[row], attributes: [.foregroundColor: UIColor.label]) 47 | } 48 | 49 | func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { 50 | onSelect?(texts[row]) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.jazzy.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/realm/jazzy 2 | 3 | author: Ryo Aoyama 4 | author_url: https://github.com/ra1028 5 | github_url: https://github.com/ra1028/Carbon 6 | module: Carbon 7 | readme: README.md 8 | output: docs 9 | theme: fullwidth 10 | clean: true 11 | xcodebuild_arguments: 12 | - -workspace 13 | - Carbon.xcworkspace 14 | - -scheme 15 | - Carbon 16 | custom_categories: 17 | - name: Element 18 | children: 19 | - Component 20 | - IdentifiableComponent 21 | - AnyComponent 22 | - Section 23 | - Group 24 | - name: ComponentWrapper 25 | children: 26 | - IdentifiedComponentWrapper 27 | - ComponentWrapping 28 | - name: Node 29 | children: 30 | - CellNode 31 | - ViewNode 32 | - name: Renderer 33 | children: 34 | - Renderer 35 | - name: Adapter 36 | children: 37 | - Adapter 38 | - UITableViewAdapter 39 | - UICollectionViewAdapter 40 | - UICollectionViewFlowLayoutAdapter 41 | - name: Updater 42 | children: 43 | - Updater 44 | - UITableViewUpdater 45 | - UITableViewReloadDataUpdater 46 | - UICollectionViewUpdater 47 | - UICollectionViewReloadDataUpdater 48 | - name: Interface 49 | children: 50 | - ComponentRenderable 51 | - UITableViewComponentCell 52 | - UITableViewComponentHeaderFooterView 53 | - UICollectionViewComponentCell 54 | - UICollectionComponentReusableView 55 | - name: Builder 56 | children: 57 | - CellsBuilder 58 | - SectionsBuilder 59 | - CellsBuildable 60 | - SectionsBuildable 61 | - Optional 62 | - name: Changeset 63 | children: 64 | - DataChangeset 65 | - StagedDataChangeset 66 | -------------------------------------------------------------------------------- /Carbon.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | /*: 2 | ## Welcome to `Carbon` Playground 3 | ---- 4 | > 1. Open Carbon.xcworkspace. 5 | > 2. Build the Carbon. 6 | > 3. Open Carbon playground in project navigator. 7 | > 4. Show the live view in assistant editor. 8 | */ 9 | import Carbon 10 | import UIKit 11 | import PlaygroundSupport 12 | 13 | // Setup 14 | 15 | let frame = CGRect(x: 0, y: 0, width: 375, height: 812) 16 | let tableView = UITableView(frame: frame, style: .grouped) 17 | tableView.estimatedSectionHeaderHeight = 44 18 | tableView.estimatedSectionFooterHeight = 44 19 | 20 | PlaygroundPage.current.needsIndefiniteExecution = true 21 | PlaygroundPage.current.liveView = tableView 22 | 23 | // Define component 24 | 25 | struct Label: Component { 26 | var text: String 27 | 28 | func renderContent() -> UILabel { 29 | return UILabel() 30 | } 31 | 32 | func render(in content: UILabel) { 33 | content.text = text 34 | } 35 | } 36 | 37 | // Create renderer 38 | 39 | let renderer = Renderer( 40 | adapter: UITableViewAdapter(), 41 | updater: UITableViewUpdater() 42 | ) 43 | 44 | renderer.target = tableView 45 | 46 | // Render 47 | 48 | renderer.render { 49 | Section(id: 0) { 50 | Label(text: "Cell 1").identified(by: \.text) 51 | Label(text: "Cell 2").identified(by: \.text) 52 | Label(text: "Cell 3").identified(by: \.text) 53 | Label(text: "Cell 4").identified(by: \.text) 54 | } 55 | 56 | Section( 57 | id: 1, 58 | header: Label(text: "Header 1"), 59 | footer: Label(text: "Footer 1"), 60 | cells: { 61 | Label(text: "Cell 5").identified(by: \.text) 62 | Label(text: "Cell 6").identified(by: \.text) 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /Tests/Adapters/MockCustomXibTableViewHeaderFooterView.xib: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /Sources/ComponentWrapper/IdentifiedComponentWrapper.swift: -------------------------------------------------------------------------------- 1 | /// A wrapper around the compoent to conform to `IdentifiableComponent`. 2 | public struct IdentifiedComponentWrapper: ComponentWrapping, IdentifiableComponent { 3 | /// A type that represents an id that used to uniquely identify the component. 4 | public var id: ID 5 | 6 | /// The wrapped component instance. 7 | public var wrapped: Wrapped 8 | 9 | /// Create a component wrapper wrapping given id and component. 10 | /// 11 | /// - Parameters: 12 | /// - id: An identifier to be wrapped. 13 | /// - wrapped: A compoennt instance to be wrapped. 14 | public init(id: ID, wrapped: Wrapped) { 15 | self.id = id 16 | self.wrapped = wrapped 17 | } 18 | } 19 | 20 | public extension Component { 21 | /// Returns an identified component wrapping `self` and given `id`. 22 | /// - Parameter: 23 | /// - id: An identifier to be wrapped. 24 | /// 25 | /// - Returns: An identified component wrapping `self` and given `id`. 26 | func identified(by id: ID) -> IdentifiedComponentWrapper { 27 | return IdentifiedComponentWrapper(id: id, wrapped: self) 28 | } 29 | 30 | /// Returns an identified component wrapping `self` and the `id` that accessed by given key path. 31 | /// - Parameter: 32 | /// - keyPath: A key path to access an identifier of the `self`. 33 | /// 34 | /// - Returns: An identified component wrapping `self` and the `id` that accessed by given key path. 35 | func identified(by keyPath: KeyPath) -> IdentifiedComponentWrapper { 36 | return identified(by: self[keyPath: keyPath]) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docs/js/jazzy.js: -------------------------------------------------------------------------------- 1 | window.jazzy = {'docset': false} 2 | if (typeof window.dash != 'undefined') { 3 | document.documentElement.className += ' dash' 4 | window.jazzy.docset = true 5 | } 6 | if (navigator.userAgent.match(/xcode/i)) { 7 | document.documentElement.className += ' xcode' 8 | window.jazzy.docset = true 9 | } 10 | 11 | function toggleItem($link, $content) { 12 | var animationDuration = 300; 13 | $link.toggleClass('token-open'); 14 | $content.slideToggle(animationDuration); 15 | } 16 | 17 | function itemLinkToContent($link) { 18 | return $link.parent().parent().next(); 19 | } 20 | 21 | // On doc load + hash-change, open any targetted item 22 | function openCurrentItemIfClosed() { 23 | if (window.jazzy.docset) { 24 | return; 25 | } 26 | var $link = $(`.token[href="${location.hash}"]`); 27 | $content = itemLinkToContent($link); 28 | if ($content.is(':hidden')) { 29 | toggleItem($link, $content); 30 | } 31 | } 32 | 33 | $(openCurrentItemIfClosed); 34 | $(window).on('hashchange', openCurrentItemIfClosed); 35 | 36 | // On item link ('token') click, toggle its discussion 37 | $('.token').on('click', function(event) { 38 | if (window.jazzy.docset) { 39 | return; 40 | } 41 | var $link = $(this); 42 | toggleItem($link, itemLinkToContent($link)); 43 | 44 | // Keeps the document from jumping to the hash. 45 | var href = $link.attr('href'); 46 | if (history.pushState) { 47 | history.pushState({}, '', href); 48 | } else { 49 | location.hash = href; 50 | } 51 | event.preventDefault(); 52 | }); 53 | 54 | // Clicks on links to the current, closed, item need to open the item 55 | $("a:not('.token')").on('click', function() { 56 | if (location == this.href) { 57 | openCurrentItemIfClosed(); 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/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 | -------------------------------------------------------------------------------- /Tests/ComponentWrapper/ComponentWrappingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Carbon 3 | 4 | final class ComponentWrappingTests: XCTestCase { 5 | func testForwardingActions() { 6 | let reuseIdentifier = "testForwardingActions" 7 | let referenceSize = CGSize(width: 200, height: 200) 8 | let intrinsicContentSize = CGSize(width: 300, height: 300) 9 | let shouldContentUpdate = true 10 | let shouldRender = true 11 | let content = UIView() 12 | 13 | let mock = MockComponent( 14 | reuseIdentifier: reuseIdentifier, 15 | referenceSize: referenceSize, 16 | intrinsicContentSize: intrinsicContentSize, 17 | shouldContentUpdate: shouldContentUpdate, 18 | shouldRender: shouldRender, 19 | content: content 20 | ) 21 | let wrapper = MockComponentWrapper(wrapped: mock) 22 | wrapper.render(in: content) 23 | wrapper.layout(content: content, in: UIView()) 24 | wrapper.contentWillDisplay(content) 25 | wrapper.contentDidEndDisplay(content) 26 | 27 | XCTAssertEqual(wrapper.reuseIdentifier, reuseIdentifier) 28 | XCTAssertEqual(wrapper.renderContent(), content) 29 | XCTAssertEqual(mock.contentCapturedOnRender, content) 30 | XCTAssertEqual(wrapper.referenceSize(in: .zero), referenceSize) 31 | XCTAssertEqual(wrapper.intrinsicContentSize(for: content), intrinsicContentSize) 32 | XCTAssertEqual(wrapper.shouldContentUpdate(with: wrapper), shouldContentUpdate) 33 | XCTAssertEqual(wrapper.shouldRender(next: wrapper, in: content), shouldRender) 34 | XCTAssertEqual(mock.contentCapturedOnLayout, content) 35 | XCTAssertEqual(mock.contentCapturedOnWillDisplay, content) 36 | XCTAssertEqual(mock.contentCapturedOnDidEndDisplay, content) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/ComponentTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Carbon 3 | 4 | final class ComponentTests: XCTestCase { 5 | func testReuseIdentifier() { 6 | let componentA = A.Component() 7 | let componentB = B.Component() 8 | 9 | XCTAssertEqual(componentA.reuseIdentifier, "Tests.A.Component") 10 | XCTAssertEqual(componentB.reuseIdentifier, "Tests.B.Component") 11 | XCTAssertNotEqual(componentA.reuseIdentifier, componentB.reuseIdentifier) 12 | } 13 | 14 | func testLayout() { 15 | let component = A.Component() 16 | let content = component.renderContent() 17 | let frame = CGRect(x: 0, y: 0, width: 200, height: 300) 18 | let container = UIView(frame: frame) 19 | 20 | component.layout(content: content, in: container) 21 | container.layoutIfNeeded() 22 | XCTAssertEqual(content.frame, frame) 23 | } 24 | 25 | func testIntrinsicContentSizeForView() { 26 | struct TestComponent: Component { 27 | func renderContent() -> UILabel { 28 | return UILabel() 29 | } 30 | 31 | func render(in content: UILabel) { 32 | content.text = "Test" 33 | } 34 | } 35 | 36 | let component = TestComponent() 37 | let content = component.renderContent() 38 | component.render(in: content) 39 | 40 | XCTAssertEqual(component.intrinsicContentSize(for: content), content.intrinsicContentSize) 41 | } 42 | 43 | func testShouldContentUpdateWhenEquatable() { 44 | let component1 = A.Component(value: 100) 45 | let component2 = A.Component(value: 200) 46 | 47 | // Always false by default. 48 | XCTAssertFalse(component1.shouldContentUpdate(with: component1)) 49 | XCTAssertFalse(component1.shouldContentUpdate(with: component2)) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/Adapters/MockCustomXibTableViewCell.xib: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /Sources/Adapters/Adapter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Represents an adapter that holds data to be rendered. 4 | public protocol Adapter: class { 5 | /// The data to be rendered. 6 | var data: [Section] { get set } 7 | } 8 | 9 | public extension Adapter { 10 | /// Returns a collection of cell nodes in the specified section. 11 | /// 12 | /// - Parameter: 13 | /// - section: The index of section containing the collection of 14 | /// cell nodes to retrieve. 15 | /// 16 | /// - Returns: A collection of cell nodes in the specified section. 17 | func cellNodes(in section: Int) -> [CellNode] { 18 | return data[section].cells 19 | } 20 | 21 | /// Returns a node of cell at the specified index path. 22 | /// 23 | /// - Parameter: 24 | /// - indexPath: The index path at the cell node to retrieve. 25 | /// 26 | /// - Returns: A node of cell at the specified index path. 27 | func cellNode(at indexPath: IndexPath) -> CellNode { 28 | return cellNodes(in: indexPath.section)[indexPath.row] 29 | } 30 | 31 | /// Returns a node of header in the specified section. 32 | /// 33 | /// - Parameter: 34 | /// - section: The index of section containing the header node 35 | /// to retrive. 36 | /// 37 | /// - Returns: A node of header in the specified section. 38 | func headerNode(in section: Int) -> ViewNode? { 39 | return data[section].header 40 | } 41 | 42 | /// Returns a node of footer in the specified section. 43 | /// 44 | /// - Parameter: 45 | /// - section: The index of section containing the footer node 46 | /// to retrive. 47 | /// 48 | /// - Returns: A node of footer in the specified section. 49 | func footerNode(in section: Int) -> ViewNode? { 50 | return data[section].footer 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/Adapters/MockCustomXibCollectionViewCell.xib: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /Sources/Adapters/UICollectionViewFlowLayoutAdapter.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// An adapter for `UICollectionView` with `UICollectionViewFlowLayout` inherited from `UICollectionViewAdapter`. 4 | open class UICollectionViewFlowLayoutAdapter: UICollectionViewAdapter {} 5 | 6 | extension UICollectionViewFlowLayoutAdapter: UICollectionViewDelegateFlowLayout { 7 | /// Returns the size for item at specified index path. 8 | open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 9 | let node = cellNode(at: indexPath) 10 | return node.component.referenceSize(in: collectionView.bounds) ?? collectionViewLayout.flowLayout?.itemSize ?? .zero 11 | } 12 | 13 | /// Returns the size for header in specified section. 14 | open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { 15 | guard let node = headerNode(in: section) else { 16 | return .zero 17 | } 18 | 19 | return node.component.referenceSize(in: collectionView.bounds) ?? collectionViewLayout.flowLayout?.headerReferenceSize ?? .zero 20 | } 21 | 22 | /// Returns the size for footer in specified section. 23 | open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { 24 | guard let node = footerNode(in: section) else { 25 | return .zero 26 | } 27 | 28 | return node.component.referenceSize(in: collectionView.bounds) ?? collectionViewLayout.flowLayout?.footerReferenceSize ?? .zero 29 | } 30 | } 31 | 32 | private extension UICollectionViewLayout { 33 | var flowLayout: UICollectionViewFlowLayout? { 34 | return self as? UICollectionViewFlowLayout 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Carbon.xcodeproj/xcshareddata/xcschemes/Tests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 17 | 23 | 24 | 25 | 26 | 27 | 37 | 38 | 44 | 45 | 47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Kyoto/KyotoViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | import MagazineLayout 4 | 5 | final class KyotoViewController: UIViewController { 6 | enum ID { 7 | case top 8 | case photo 9 | } 10 | 11 | @IBOutlet var collectionView: UICollectionView! 12 | 13 | private let renderer = Renderer( 14 | adapter: KyotoMagazineLayoutAdapter(), 15 | updater: UICollectionViewUpdater() 16 | ) 17 | 18 | override var supportedInterfaceOrientations: UIInterfaceOrientationMask { 19 | .portrait 20 | } 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | 25 | title = "Kyoto" 26 | 27 | let layout = MagazineLayout() 28 | collectionView.collectionViewLayout = layout 29 | renderer.target = collectionView 30 | 31 | renderer.render { 32 | Section( 33 | id: ID.top, 34 | header: KyotoTop() 35 | ) 36 | 37 | Section( 38 | id: ID.photo, 39 | header: Header("PHOTOS"), 40 | footer: KyotoLicense { 41 | let url = URL(string: "https://unsplash.com/")! 42 | UIApplication.shared.open(url) 43 | }, 44 | cells: { 45 | KyotoImage(title: "Fushimi Inari-taisha", image: #imageLiteral(resourceName: "KyotoFushimiInari")) 46 | KyotoImage(title: "Arashiyama", image: #imageLiteral(resourceName: "KyotoArashiyama")) 47 | KyotoImage(title: "Byōdō-in", image: #imageLiteral(resourceName: "KyotoByōdōIn")) 48 | KyotoImage(title: "Gion", image: #imageLiteral(resourceName: "KyotoGion")) 49 | KyotoImage(title: "Kiyomizu-dera", image: #imageLiteral(resourceName: "KyotoKiyomizuDera")) 50 | }) 51 | } 52 | } 53 | 54 | override func viewDidLayoutSubviews() { 55 | super.viewDidLayoutSubviews() 56 | collectionView.performBatchUpdates(nil) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/SwiftUISupport/ComponentSwiftUISupport.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) && canImport(Combine) 2 | 3 | import SwiftUI 4 | 5 | @available(iOS 13.0, *) 6 | public extension Component where Self: View { 7 | /// Declares the content and behavior of this view. 8 | var body: some View { 9 | ComponentView(self) 10 | } 11 | } 12 | 13 | @available(iOS 13.0, *) 14 | private struct ComponentView: View { 15 | var component: C 16 | var proxy = ComponentViewProxy() 17 | 18 | init(_ component: C) { 19 | self.component = component 20 | } 21 | 22 | var body: some View { 23 | ComponentRepresenting(component: component, proxy: proxy) 24 | .onAppear { self.proxy.uiView?.contentWillDisplay() } 25 | .onDisappear { self.proxy.uiView?.contentDidEndDisplay() } 26 | } 27 | } 28 | 29 | private struct ComponentRepresenting: UIViewRepresentable { 30 | var component: C 31 | var proxy: ComponentViewProxy 32 | 33 | func makeUIView(context: Context) -> UIComponentView { 34 | UIComponentView() 35 | } 36 | 37 | func updateUIView(_ uiView: UIComponentView, context: Context) { 38 | uiView.render(component: AnyComponent(component)) 39 | proxy.uiView = uiView 40 | } 41 | } 42 | 43 | private final class UIComponentView: UIView, ComponentRenderable { 44 | @available(*, unavailable) 45 | required init?(coder: NSCoder) { 46 | fatalError("init(coder:) has not been implemented") 47 | } 48 | 49 | override init(frame: CGRect) { 50 | super.init(frame: frame) 51 | 52 | backgroundColor = .clear 53 | } 54 | 55 | override var intrinsicContentSize: CGSize { 56 | if let referenceSize = renderedComponent?.referenceSize(in: bounds) { 57 | return referenceSize 58 | } 59 | else if let component = renderedComponent, let content = renderedContent { 60 | return component.intrinsicContentSize(for: content) 61 | } 62 | else { 63 | return super.intrinsicContentSize 64 | } 65 | } 66 | } 67 | 68 | private final class ComponentViewProxy { 69 | var uiView: UIComponentView? 70 | } 71 | 72 | #endif 73 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/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 | "size" : "60x60", 35 | "idiom" : "iphone", 36 | "filename" : "120.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "60x60", 41 | "idiom" : "iphone", 42 | "filename" : "180.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "idiom" : "ipad", 47 | "size" : "20x20", 48 | "scale" : "1x" 49 | }, 50 | { 51 | "idiom" : "ipad", 52 | "size" : "20x20", 53 | "scale" : "2x" 54 | }, 55 | { 56 | "idiom" : "ipad", 57 | "size" : "29x29", 58 | "scale" : "1x" 59 | }, 60 | { 61 | "idiom" : "ipad", 62 | "size" : "29x29", 63 | "scale" : "2x" 64 | }, 65 | { 66 | "idiom" : "ipad", 67 | "size" : "40x40", 68 | "scale" : "1x" 69 | }, 70 | { 71 | "idiom" : "ipad", 72 | "size" : "40x40", 73 | "scale" : "2x" 74 | }, 75 | { 76 | "idiom" : "ipad", 77 | "size" : "76x76", 78 | "scale" : "1x" 79 | }, 80 | { 81 | "idiom" : "ipad", 82 | "size" : "76x76", 83 | "scale" : "2x" 84 | }, 85 | { 86 | "idiom" : "ipad", 87 | "size" : "83.5x83.5", 88 | "scale" : "2x" 89 | }, 90 | { 91 | "size" : "1024x1024", 92 | "idiom" : "ios-marketing", 93 | "filename" : "1024.png", 94 | "scale" : "1x" 95 | } 96 | ], 97 | "info" : { 98 | "version" : 1, 99 | "author" : "xcode" 100 | } 101 | } -------------------------------------------------------------------------------- /docs/js/jazzy.search.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | var $typeahead = $('[data-typeahead]'); 3 | var $form = $typeahead.parents('form'); 4 | var searchURL = $form.attr('action'); 5 | 6 | function displayTemplate(result) { 7 | return result.name; 8 | } 9 | 10 | function suggestionTemplate(result) { 11 | var t = '
'; 12 | t += '' + result.name + ''; 13 | if (result.parent_name) { 14 | t += '' + result.parent_name + ''; 15 | } 16 | t += '
'; 17 | return t; 18 | } 19 | 20 | $typeahead.one('focus', function() { 21 | $form.addClass('loading'); 22 | 23 | $.getJSON(searchURL).then(function(searchData) { 24 | const searchIndex = lunr(function() { 25 | this.ref('url'); 26 | this.field('name'); 27 | this.field('abstract'); 28 | for (const [url, doc] of Object.entries(searchData)) { 29 | this.add({url: url, name: doc.name, abstract: doc.abstract}); 30 | } 31 | }); 32 | 33 | $typeahead.typeahead( 34 | { 35 | highlight: true, 36 | minLength: 3, 37 | autoselect: true 38 | }, 39 | { 40 | limit: 10, 41 | display: displayTemplate, 42 | templates: { suggestion: suggestionTemplate }, 43 | source: function(query, sync) { 44 | const lcSearch = query.toLowerCase(); 45 | const results = searchIndex.query(function(q) { 46 | q.term(lcSearch, { boost: 100 }); 47 | q.term(lcSearch, { 48 | boost: 10, 49 | wildcard: lunr.Query.wildcard.TRAILING 50 | }); 51 | }).map(function(result) { 52 | var doc = searchData[result.ref]; 53 | doc.url = result.ref; 54 | return doc; 55 | }); 56 | sync(results); 57 | } 58 | } 59 | ); 60 | $form.removeClass('loading'); 61 | $typeahead.trigger('focus'); 62 | }); 63 | }); 64 | 65 | var baseURL = searchURL.slice(0, -"search.json".length); 66 | 67 | $typeahead.on('typeahead:select', function(e, result) { 68 | window.location = baseURL + result.url; 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /Tests/Interfaces/ComponentContainerElementTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Carbon 3 | 4 | final class ComponentContainerTests: XCTestCase { 5 | func testContentWillDisplay() { 6 | let container = MockComponentContainer() 7 | let content = UIView() 8 | let component = MockComponent(content: content) 9 | 10 | container.render(component: AnyComponent(component)) 11 | container.contentWillDisplay() 12 | 13 | if let captured = component.contentCapturedOnWillDisplay { 14 | XCTAssertEqual(captured, content) 15 | } 16 | else { 17 | XCTFail() 18 | } 19 | } 20 | 21 | func testContentDidEndDisplay() { 22 | let container = MockComponentContainer() 23 | let content = UIView() 24 | let component = MockComponent(content: content) 25 | 26 | container.render(component: AnyComponent(component)) 27 | container.contentDidEndDisplay() 28 | 29 | if let captured = component.contentCapturedOnDidEndDisplay { 30 | XCTAssertEqual(captured, content) 31 | } 32 | else { 33 | XCTFail() 34 | } 35 | } 36 | 37 | func testRenderComponent() { 38 | let component = MockComponent(shouldRender: false) 39 | let componentContainerView = UIView() 40 | let container = MockComponentContainer(componentContainerView: componentContainerView) 41 | 42 | container.render(component: AnyComponent(component)) 43 | 44 | XCTAssertEqual(component.contentCapturedOnLayout, container.renderedContent as? UIView?) 45 | XCTAssertEqual(component.contentCapturedOnRender, container.renderedContent as? UIView?) 46 | XCTAssertEqual(component, container.renderedComponent?.as(MockComponent.self)) 47 | } 48 | 49 | func testShouldRenderComponent() { 50 | let component = A.Component(value: 100) 51 | let container = MockComponentContainer() 52 | 53 | container.render(component: AnyComponent(component)) 54 | 55 | let nextComponent = A.Component(value: 200) 56 | 57 | container.render(component: AnyComponent(nextComponent)) 58 | 59 | if let renderedComponent = container.renderedComponent?.as(A.Component.self) { 60 | XCTAssertEqual(renderedComponent.value, 100) 61 | } 62 | else { 63 | XCTFail() 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/FunctionBuilder/Group.swift: -------------------------------------------------------------------------------- 1 | /// An affordance for grouping component or section. 2 | /// 3 | /// Example for simple grouping of cells. 4 | /// 5 | /// Group { 6 | /// Label("Cell 0") 7 | /// 8 | /// Label("Cell 1") 9 | /// 10 | /// Label("Cell 2") 11 | /// } 12 | public struct Group { 13 | @usableFromInline 14 | internal var elements: [Element] 15 | 16 | /// Creates a group without elements. 17 | public init() { 18 | elements = [] 19 | } 20 | } 21 | 22 | extension Group: CellsBuildable where Element == CellNode { 23 | /// Creates a group with given cells. 24 | /// 25 | /// - Parameter 26 | /// - cells: A closure that constructs cells. 27 | public init(@CellsBuilder cells: () -> C) { 28 | elements = cells().buildCells() 29 | } 30 | 31 | /// Creates a group with cells mapped from given elements. 32 | /// 33 | /// - Parameter 34 | /// - data: The sequence of elements to be mapped to cells. 35 | /// - cell: A closure to create a cell with passed element. 36 | public init(of data: Data, cell: (Data.Element) -> C) { 37 | elements = data.flatMap { element in 38 | cell(element).buildCells() 39 | } 40 | } 41 | 42 | /// Build an array of cell. 43 | /// 44 | /// - Returns: An array of cell. 45 | public func buildCells() -> [CellNode] { 46 | elements 47 | } 48 | } 49 | 50 | extension Group: SectionsBuildable where Element == Section { 51 | /// Creates a group with given sections. 52 | /// 53 | /// - Parameter 54 | /// - sections: A closure that constructs sections. 55 | public init(@SectionsBuilder sections: () -> S) { 56 | elements = sections().buildSections() 57 | } 58 | 59 | /// Creates a group with sections mapped from given elements. 60 | /// 61 | /// - Parameter 62 | /// - data: The sequence of elements to be mapped to sections. 63 | /// - section: A closure to create a section with passed element. 64 | public init(of data: Data, section: (Data.Element) -> S) { 65 | elements = data.flatMap { element in 66 | section(element).buildSections() 67 | } 68 | } 69 | 70 | /// Build an array of section. 71 | /// 72 | /// - Returns: An array of section. 73 | public func buildSections() -> [Section] { 74 | elements 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Top/HomeViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import Carbon 4 | 5 | final class HomeViewController: UIViewController { 6 | enum Destination { 7 | case hello 8 | case pangram 9 | case kyoto 10 | case emoji 11 | case todo 12 | case form 13 | case kyotoSwiftUI 14 | } 15 | 16 | @IBOutlet var tableView: UITableView! 17 | 18 | private let renderer = Renderer( 19 | adapter: UITableViewAdapter(), 20 | updater: UITableViewUpdater() 21 | ) 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | 26 | title = "Home" 27 | renderer.target = tableView 28 | 29 | renderer.render { 30 | Header("EXAMPLES") 31 | .identified(by: \.title) 32 | 33 | HomeItem(title: "👋 Hello") { [weak self] in 34 | self?.push(.hello) 35 | } 36 | 37 | HomeItem(title: "🔠 Pangram") { [weak self] in 38 | self?.push(.pangram) 39 | } 40 | 41 | HomeItem(title: "⛩ Kyoto") { [weak self] in 42 | self?.push(.kyoto) 43 | } 44 | 45 | HomeItem(title: "😀 Shuffle Emoji") { [weak self] in 46 | self?.push(.emoji) 47 | } 48 | 49 | HomeItem(title: "📋 Todo App") { [weak self] in 50 | self?.push(.todo) 51 | } 52 | 53 | HomeItem(title: "👤 Profile Form") { [weak self] in 54 | self?.push(.form) 55 | } 56 | 57 | HomeItem(title: "⛩ Kyoto SwiftUI") { [weak self] in 58 | self?.push(.kyotoSwiftUI) 59 | } 60 | } 61 | } 62 | 63 | func push(_ destination: Destination) { 64 | let controller: UIViewController 65 | 66 | switch destination { 67 | case .hello: 68 | controller = HelloViewController() 69 | 70 | case .pangram: 71 | controller = PangramViewController() 72 | 73 | case .kyoto: 74 | controller = KyotoViewController() 75 | 76 | case .emoji: 77 | controller = EmojiViewController() 78 | 79 | case .todo: 80 | controller = TodoViewController() 81 | 82 | case .form: 83 | controller = FormViewController() 84 | 85 | case .kyotoSwiftUI: 86 | controller = HostingController(rootView: KyotoSwiftUIView()) 87 | } 88 | 89 | navigationController?.pushViewController(controller, animated: true) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/Nodes/CellNode.swift: -------------------------------------------------------------------------------- 1 | import DifferenceKit 2 | 3 | /// The node for cell that can be uniquely identified. 4 | /// Wrapping type-erased identifier and component. 5 | /// This works as an intermediary for `DifferenceKit`. 6 | public struct CellNode { 7 | /// A type-erased identifier that can be used to uniquely 8 | /// identify the component. 9 | public var id: AnyHashable 10 | 11 | /// A type-erased component which wrapped in `self`. 12 | public var component: AnyComponent 13 | 14 | /// Create a node wrapping given id and component. 15 | /// 16 | /// - Parameters: 17 | /// - id: An identifier to be wrapped. 18 | /// - component: A component to be wrapped. 19 | public init(id: I, _ component: C) { 20 | // This is workaround for avoid redundant `AnyHashable` wrapping. 21 | if type(of: id) == AnyHashable.self { 22 | self.id = unsafeBitCast(id, to: AnyHashable.self) 23 | } 24 | else { 25 | self.id = id 26 | } 27 | 28 | self.component = AnyComponent(component) 29 | } 30 | 31 | /// Create a node wrapping given component and its id. 32 | /// 33 | /// - Parameter 34 | /// - component: A component to be wrapped that can be uniquely identified. 35 | @inlinable 36 | public init(_ component: C) { 37 | self.init(id: component.id, component) 38 | } 39 | 40 | /// Returns a base instance of component casted as given type if possible. 41 | /// 42 | /// - Parameter: An expected type of the base instance of component to casted. 43 | /// - Returns: A casted base instance. 44 | @inlinable 45 | public func component(as _: T.Type) -> T? { 46 | return component.as(T.self) 47 | } 48 | } 49 | 50 | extension CellNode: CellsBuildable { 51 | public func buildCells() -> [CellNode] { 52 | return [self] 53 | } 54 | } 55 | 56 | extension CellNode: Differentiable { 57 | /// An identifier value for difference calculation. 58 | @inlinable 59 | public var differenceIdentifier: AnyHashable { 60 | return id 61 | } 62 | 63 | /// Indicate whether the content of `self` is equals to the content of 64 | /// the given source value. 65 | @inlinable 66 | public func isContentEqual(to source: CellNode) -> Bool { 67 | return !source.component.shouldContentUpdate(with: component) 68 | } 69 | } 70 | 71 | extension CellNode: CustomDebugStringConvertible { 72 | /// A textual representation of this instance, suitable for debugging. 73 | @inlinable 74 | public var debugDescription: String { 75 | return "CellNode(id: \(id), component: \(component))" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Tests/Nodes/CellNodeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Carbon 3 | 4 | final class CellNodeTests: XCTestCase { 5 | func testInitWithComponent() { 6 | let component = A.Component() 7 | let node = CellNode(id: TestID.a, component) 8 | 9 | XCTAssertEqual(node.id.base as? TestID, .a) 10 | XCTAssertEqual(node.component.as(A.Component.self), component) 11 | } 12 | 13 | func testInitWithIdentifiedComponentWrapper() { 14 | let component = A.Component() 15 | let node = CellNode(component.identified(by: \.value)) 16 | 17 | XCTAssertEqual(node.id.base as? Int, component.value) 18 | XCTAssertEqual(node.component.as(IdentifiedComponentWrapper.self)?.wrapped, component) 19 | } 20 | 21 | func testInitWithIdentifiableComponent() { 22 | let component = MockIdentifiableComponent(id: TestID.a) 23 | let node = CellNode(component) 24 | 25 | XCTAssertEqual(node.id.base as? TestID, .a) 26 | XCTAssertEqual(node.component.as(MockIdentifiableComponent.self), component) 27 | } 28 | 29 | func testComponentCasting() { 30 | let component = A.Component() 31 | let node = CellNode(id: TestID.a, component) 32 | 33 | XCTAssertNil(node.component(as: Never.self)) 34 | XCTAssertNotNil(node.component(as: A.Component.self)) 35 | } 36 | 37 | func testContentEquatableConformance() { 38 | let component1 = MockComponent(shouldContentUpdate: true) 39 | let component2 = MockComponent(shouldContentUpdate: false) 40 | let node1 = CellNode(id: TestID.a, component1) 41 | let node2 = CellNode(id: TestID.b, component2) 42 | 43 | XCTAssertEqual(node1.isContentEqual(to: node1), false) 44 | XCTAssertEqual(node2.isContentEqual(to: node2), true) 45 | } 46 | 47 | func testDifferentiableConformance() { 48 | let component1 = MockComponent() 49 | let component2 = MockIdentifiableComponent(id: TestID.b) 50 | let node1 = CellNode(id: TestID.a, component1) 51 | let node2 = CellNode(component2) 52 | let node3 = CellNode(id: TestID.c, component2) 53 | 54 | XCTAssertEqual(node1.differenceIdentifier.base as? TestID, .a) 55 | XCTAssertEqual(node2.differenceIdentifier.base as? TestID, .b) 56 | XCTAssertEqual(node3.differenceIdentifier.base as? TestID, .c) 57 | } 58 | 59 | func testAvoidRedundantAnyHashableWrappingForID() { 60 | let component = MockIdentifiableComponent(id: AnyHashable(TestID.a)) 61 | let node1 = CellNode(component) 62 | let node2 = CellNode(id: AnyHashable(TestID.b), component) 63 | 64 | XCTAssertEqual(node1.id, component.id) 65 | XCTAssertEqual(node2.id, TestID.b) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Common/FooterContent.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /Tests/FunctionBuilder/GroupTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Carbon 3 | 4 | final class GroupTests: XCTestCase { 5 | func testInitWithoutElements() { 6 | let group = Group() 7 | XCTAssertTrue(group.elements.isEmpty) 8 | } 9 | 10 | func testCellsWithFunctionBuilder() { 11 | let componentA = A.Component() 12 | let componentB = B.Component() 13 | let componentA2 = A.Component() 14 | let componentB2 = B.Component() 15 | let appearsComponentB2 = false 16 | 17 | let group = Group { 18 | componentA 19 | componentB 20 | 21 | if true { 22 | componentA2 23 | } 24 | 25 | if appearsComponentB2 { 26 | componentB2 27 | } 28 | } 29 | 30 | let cells = group.buildCells() 31 | 32 | XCTAssertEqual(cells.count, 3) 33 | XCTAssertEqual(cells[0].component(as: A.Component.self), componentA) 34 | XCTAssertEqual(cells[1].component(as: B.Component.self), componentB) 35 | XCTAssertEqual(cells[2].component(as: A.Component.self), componentA2) 36 | } 37 | 38 | func testCellsWithSequence() { 39 | let range = 0..<3 40 | let group = Group(of: range) { value in 41 | A.Component(value: value) 42 | } 43 | 44 | let cells = group.buildCells() 45 | 46 | XCTAssertEqual(cells.count, 3) 47 | XCTAssertEqual(cells[0].component(as: A.Component.self)?.value, 0) 48 | XCTAssertEqual(cells[1].component(as: A.Component.self)?.value, 1) 49 | XCTAssertEqual(cells[2].component(as: A.Component.self)?.value, 2) 50 | } 51 | 52 | func testSectionsWithFunctionBuilder() { 53 | let section0 = Section(id: 0) 54 | let section1 = Section(id: 1) 55 | let section2 = Section(id: 2) 56 | let section3 = Section(id: 3) 57 | let appearsSection3 = false 58 | 59 | let group = Group { 60 | section0 61 | section1 62 | 63 | if true { 64 | section2 65 | } 66 | 67 | if appearsSection3 { 68 | section3 69 | } 70 | } 71 | 72 | let sections = group.buildSections() 73 | 74 | XCTAssertEqual(sections.count, 3) 75 | XCTAssertEqual(sections[0].id, 0) 76 | XCTAssertEqual(sections[1].id, 1) 77 | XCTAssertEqual(sections[2].id, 2) 78 | } 79 | 80 | func testSectionsWithSequence() { 81 | let range = 0..<3 82 | let group = Group(of: range) { value in 83 | Section(id: value) 84 | } 85 | 86 | let sections = group.buildSections() 87 | 88 | XCTAssertEqual(sections.count, 3) 89 | XCTAssertEqual(sections[0].id, 0) 90 | XCTAssertEqual(sections[1].id, 1) 91 | XCTAssertEqual(sections[2].id, 2) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Todo/TodoEmptyContent.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Form/FormTextPickerContent.xib: -------------------------------------------------------------------------------- 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 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.0) 5 | activesupport (4.2.11.1) 6 | i18n (~> 0.7) 7 | minitest (~> 5.1) 8 | thread_safe (~> 0.3, >= 0.3.4) 9 | tzinfo (~> 1.1) 10 | atomos (0.1.3) 11 | claide (1.0.2) 12 | cocoapods (1.7.5) 13 | activesupport (>= 4.0.2, < 5) 14 | claide (>= 1.0.2, < 2.0) 15 | cocoapods-core (= 1.7.5) 16 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 17 | cocoapods-downloader (>= 1.2.2, < 2.0) 18 | cocoapods-plugins (>= 1.0.0, < 2.0) 19 | cocoapods-search (>= 1.0.0, < 2.0) 20 | cocoapods-stats (>= 1.0.0, < 2.0) 21 | cocoapods-trunk (>= 1.3.1, < 2.0) 22 | cocoapods-try (>= 1.1.0, < 2.0) 23 | colored2 (~> 3.1) 24 | escape (~> 0.0.4) 25 | fourflusher (>= 2.3.0, < 3.0) 26 | gh_inspector (~> 1.0) 27 | molinillo (~> 0.6.6) 28 | nap (~> 1.0) 29 | ruby-macho (~> 1.4) 30 | xcodeproj (>= 1.10.0, < 2.0) 31 | cocoapods-core (1.7.5) 32 | activesupport (>= 4.0.2, < 6) 33 | fuzzy_match (~> 2.0.4) 34 | nap (~> 1.0) 35 | cocoapods-deintegrate (1.0.4) 36 | cocoapods-downloader (1.2.2) 37 | cocoapods-plugins (1.0.0) 38 | nap 39 | cocoapods-search (1.0.0) 40 | cocoapods-stats (1.1.0) 41 | cocoapods-trunk (1.3.1) 42 | nap (>= 0.8, < 2.0) 43 | netrc (~> 0.11) 44 | cocoapods-try (1.1.0) 45 | colored2 (3.1.2) 46 | concurrent-ruby (1.1.5) 47 | escape (0.0.4) 48 | ffi (1.11.1) 49 | fourflusher (2.3.1) 50 | fuzzy_match (2.0.4) 51 | gh_inspector (1.1.3) 52 | i18n (0.9.5) 53 | concurrent-ruby (~> 1.0) 54 | jazzy (0.10.0) 55 | cocoapods (~> 1.5) 56 | mustache (~> 1.1) 57 | open4 58 | redcarpet (~> 3.4) 59 | rouge (>= 2.0.6, < 4.0) 60 | sass (~> 3.6) 61 | sqlite3 (~> 1.3) 62 | xcinvoke (~> 0.3.0) 63 | liferaft (0.0.6) 64 | minitest (5.11.3) 65 | molinillo (0.6.6) 66 | mustache (1.1.0) 67 | nanaimo (0.2.6) 68 | nap (1.1.0) 69 | netrc (0.11.0) 70 | open4 (1.3.4) 71 | rb-fsevent (0.10.3) 72 | rb-inotify (0.10.0) 73 | ffi (~> 1.0) 74 | redcarpet (3.5.0) 75 | rouge (3.10.0) 76 | ruby-macho (1.4.0) 77 | sass (3.7.4) 78 | sass-listen (~> 4.0.0) 79 | sass-listen (4.0.0) 80 | rb-fsevent (~> 0.9, >= 0.9.4) 81 | rb-inotify (~> 0.9, >= 0.9.7) 82 | sqlite3 (1.4.1) 83 | thread_safe (0.3.6) 84 | tzinfo (1.2.5) 85 | thread_safe (~> 0.1) 86 | xcinvoke (0.3.0) 87 | liferaft (~> 0.0.6) 88 | xcodeproj (1.11.0) 89 | CFPropertyList (>= 2.3.3, < 4.0) 90 | atomos (~> 0.1.3) 91 | claide (>= 1.0.2, < 2.0) 92 | colored2 (~> 3.1) 93 | nanaimo (~> 0.2.6) 94 | 95 | PLATFORMS 96 | ruby 97 | 98 | DEPENDENCIES 99 | cocoapods (= 1.7.5) 100 | jazzy (= 0.10.0) 101 | 102 | BUNDLED WITH 103 | 1.17.1 104 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Form/FormDatePickerContent.xib: -------------------------------------------------------------------------------- 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 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Common/HeaderContent.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Form/FormViewController.xib: -------------------------------------------------------------------------------- 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 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Hello/HelloViewController.xib: -------------------------------------------------------------------------------- 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 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Kyoto/KyotoLicenseContent.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Top/HomeViewController.xib: -------------------------------------------------------------------------------- 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 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Hello/HelloMessageContent.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Emoji/EmojiLabelContent.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Tests/Internal/UIScrollViewExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Carbon 3 | 4 | final class MockScrollViewExtensionsTests: XCTestCase { 5 | func testIsScrolling() { 6 | let scrollView1 = MockScrollView() 7 | scrollView1._isTracking = true 8 | 9 | let scrollView2 = MockScrollView() 10 | scrollView2._isDragging = true 11 | 12 | let scrollView3 = MockScrollView() 13 | scrollView3._isDecelerating = true 14 | 15 | XCTAssertTrue(scrollView1._isScrolling) 16 | XCTAssertTrue(scrollView2._isScrolling) 17 | XCTAssertTrue(scrollView3._isScrolling) 18 | } 19 | 20 | func testSetAdjustedContentOffsetIfNeeded() { 21 | let scrollView1 = MockScrollView() 22 | let expectedContentOffset1 = CGPoint(x: 50, y: 50) 23 | scrollView1.contentInset = UIEdgeInsets(top: 1, left: 2, bottom: 3, right: 4) 24 | scrollView1.contentSize = CGSize(width: 100, height: 200) 25 | scrollView1.bounds.size = CGSize(width: 30, height: 40) 26 | scrollView1.contentOffset = CGPoint(x: 10, y: 20) 27 | scrollView1._setAdjustedContentOffsetIfNeeded(expectedContentOffset1) 28 | XCTAssertEqual(scrollView1.contentOffset, expectedContentOffset1) 29 | 30 | let scrollView2 = MockScrollView() 31 | scrollView2.contentInset = UIEdgeInsets(top: 1, left: 2, bottom: 3, right: 4) 32 | scrollView2.contentSize = CGSize(width: 100, height: 20) 33 | scrollView2.bounds.size = CGSize(width: 30, height: 40) 34 | scrollView2.contentOffset = CGPoint(x: 10, y: 20) 35 | scrollView2._setAdjustedContentOffsetIfNeeded(CGPoint(x: 50, y: 50)) 36 | XCTAssertEqual(scrollView2.contentOffset, CGPoint(x: 10, y: 20)) 37 | 38 | let scrollView3 = MockScrollView() 39 | scrollView3._isTracking = true 40 | scrollView3._setAdjustedContentOffsetIfNeeded(CGPoint(x: 50, y: 50)) 41 | XCTAssertEqual(scrollView3.contentOffset, .zero) 42 | 43 | let scrollView4 = MockScrollView() 44 | scrollView4._isDragging = true 45 | scrollView4._setAdjustedContentOffsetIfNeeded(CGPoint(x: 50, y: 50)) 46 | XCTAssertEqual(scrollView4.contentOffset, .zero) 47 | 48 | let scrollView5 = MockScrollView() 49 | scrollView5._isDecelerating = true 50 | scrollView5._setAdjustedContentOffsetIfNeeded(CGPoint(x: 50, y: 50)) 51 | XCTAssertEqual(scrollView5.contentOffset, .zero) 52 | 53 | let scrollView6 = MockScrollView() 54 | scrollView6.contentInset = UIEdgeInsets(top: 1, left: 2, bottom: 3, right: 4) 55 | scrollView6.contentSize = CGSize(width: 200, height: 200) 56 | scrollView6.bounds.size = CGSize(width: 30, height: 40) 57 | scrollView6.contentOffset = CGPoint(x: 10, y: 20) 58 | scrollView6._setAdjustedContentOffsetIfNeeded(CGPoint(x: 100, y: 100)) 59 | XCTAssertEqual(scrollView6.contentOffset, CGPoint(x: 100, y: 100)) 60 | 61 | let scrollView7 = MockScrollView() 62 | scrollView7.contentInset = UIEdgeInsets(top: 1, left: 2, bottom: 3, right: 4) 63 | scrollView7.contentSize = CGSize(width: 100, height: 200) 64 | scrollView7.bounds.size = CGSize(width: 30, height: 40) 65 | scrollView7.contentOffset = CGPoint(x: 10, y: 20) 66 | scrollView7._setAdjustedContentOffsetIfNeeded(CGPoint(x: 100, y: 200)) 67 | XCTAssertEqual(scrollView7.contentOffset, CGPoint(x: 74, y: 163)) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/Interfaces/ComponentRenderable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Represents a container that can render a component. 4 | public protocol ComponentRenderable: class { 5 | /// The container view to be render a component. 6 | var componentContainerView: UIView { get } 7 | } 8 | 9 | private let renderedContentAssociation = RuntimeAssociation(default: nil) 10 | private let renderedComponentAssociation = RuntimeAssociation(default: nil) 11 | 12 | public extension ComponentRenderable { 13 | /// A content of component that rendered on container. 14 | private(set) var renderedContent: Any? { 15 | get { return renderedContentAssociation[self] } 16 | set { renderedContentAssociation[self] = newValue } 17 | } 18 | 19 | /// A component that latest rendered on container. 20 | private(set) var renderedComponent: AnyComponent? { 21 | get { return renderedComponentAssociation[self] } 22 | set { renderedComponentAssociation[self] = newValue } 23 | } 24 | } 25 | 26 | internal extension ComponentRenderable { 27 | /// Invoked every time of before a component got into visible area. 28 | func contentWillDisplay() { 29 | guard let content = renderedContent else { return } 30 | 31 | renderedComponent?.contentWillDisplay(content) 32 | } 33 | 34 | /// Invoked every time of after a component went out from visible area. 35 | func contentDidEndDisplay() { 36 | guard let content = renderedContent else { return } 37 | 38 | renderedComponent?.contentDidEndDisplay(content) 39 | } 40 | 41 | /// Render given componet to container. 42 | /// 43 | /// - Parameter: 44 | /// - component: A component to be rendered. 45 | func render(component: AnyComponent) { 46 | switch (renderedContent, renderedComponent) { 47 | case (let content?, let renderedComponent?) where !renderedComponent.shouldRender(next: component, in: content): 48 | break 49 | 50 | case (let content?, _): 51 | component.render(in: content) 52 | renderedComponent = component 53 | 54 | case (nil, _): 55 | let content = component.renderContent() 56 | component.layout(content: content, in: componentContainerView) 57 | renderedContent = content 58 | render(component: component) 59 | } 60 | } 61 | } 62 | 63 | public extension ComponentRenderable where Self: UIView { 64 | /// The container view to be render a component. 65 | var componentContainerView: UIView { 66 | return self 67 | } 68 | } 69 | 70 | public extension ComponentRenderable where Self: UITableViewCell { 71 | /// The container view to be render a component. 72 | var componentContainerView: UIView { 73 | return contentView 74 | } 75 | } 76 | 77 | public extension ComponentRenderable where Self: UITableViewHeaderFooterView { 78 | /// The container view to be render a component. 79 | var componentContainerView: UIView { 80 | return contentView 81 | } 82 | } 83 | 84 | public extension ComponentRenderable where Self: UICollectionViewCell { 85 | /// The container view to be render a component. 86 | var componentContainerView: UIView { 87 | return contentView 88 | } 89 | } 90 | 91 | public extension ComponentRenderable where Self: UICollectionReusableView { 92 | /// The container view to be render a component. 93 | var componentContainerView: UIView { 94 | return self 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Kyoto/KyotoViewController.xib: -------------------------------------------------------------------------------- 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 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq -------------------------------------------------------------------------------- /Tests/SwiftUISupport/ComponentSwiftUISupportTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) && canImport(Combine) 2 | 3 | import XCTest 4 | import SwiftUI 5 | @testable import Carbon 6 | 7 | final class ComponentSwiftUISupportTests: XCTestCase { 8 | func testDisplayLifecycle() { 9 | guard #available(iOS 13.0, *) else { 10 | return 11 | } 12 | 13 | struct TestComponent: Component, View { 14 | var willDisplay: () -> Void 15 | var didEndDisplay: () -> Void 16 | 17 | func renderContent() -> UIView { 18 | UIView() 19 | } 20 | 21 | func render(in content: UIView) {} 22 | 23 | func contentWillDisplay(_ content: UIView) { 24 | willDisplay() 25 | } 26 | 27 | func contentDidEndDisplay(_ content: UIView) { 28 | didEndDisplay() 29 | } 30 | } 31 | 32 | var isWillDisplayCalled = false 33 | var isDidEndDisplayCalled = false 34 | let component = TestComponent( 35 | willDisplay: { isWillDisplayCalled = true }, 36 | didEndDisplay: { isDidEndDisplayCalled = true } 37 | ) 38 | let hostingController = UIHostingController(rootView: AnyView(component.body)) 39 | 40 | XCTAssertFalse(isWillDisplayCalled) 41 | XCTAssertFalse(isDidEndDisplayCalled) 42 | 43 | let window = UIWindow() 44 | window.rootViewController = hostingController 45 | window.isHidden = false 46 | window.setNeedsLayout() 47 | window.layoutIfNeeded() 48 | 49 | XCTAssertTrue(isWillDisplayCalled) 50 | XCTAssertFalse(isDidEndDisplayCalled) 51 | 52 | hostingController.rootView = AnyView(EmptyView()) 53 | window.setNeedsLayout() 54 | window.layoutIfNeeded() 55 | 56 | XCTAssertTrue(isWillDisplayCalled) 57 | XCTAssertTrue(isDidEndDisplayCalled) 58 | } 59 | 60 | func testReferenceSize() { 61 | guard #available(iOS 13.0, *) else { 62 | return 63 | } 64 | 65 | struct TestComponent: Component, View { 66 | static let testSize = CGSize(width: 123, height: 456) 67 | 68 | func renderContent() -> UIView { 69 | UIView() 70 | } 71 | 72 | func render(in content: UIView) {} 73 | 74 | func referenceSize(in bounds: CGRect) -> CGSize? { 75 | Self.testSize 76 | } 77 | } 78 | 79 | let component = TestComponent() 80 | let hostingController = UIHostingController(rootView: component.body) 81 | 82 | XCTAssertEqual(hostingController.view.sizeThatFits(.zero), TestComponent.testSize) 83 | } 84 | 85 | func testIntrinsicContentSize() { 86 | guard #available(iOS 13.0, *) else { 87 | return 88 | } 89 | 90 | struct TestComponent: Component, View { 91 | static let testSize = CGSize(width: 123, height: 456) 92 | 93 | func renderContent() -> UIView { 94 | UIView() 95 | } 96 | 97 | func render(in content: UIView) {} 98 | 99 | func intrinsicContentSize(for content: UIView) -> CGSize { 100 | Self.testSize 101 | } 102 | } 103 | 104 | let component = TestComponent() 105 | let hostingController = UIHostingController(rootView: component.body) 106 | 107 | XCTAssertEqual(hostingController.view.sizeThatFits(.zero), TestComponent.testSize) 108 | } 109 | } 110 | 111 | #endif 112 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Example-iOS.xcodeproj/xcshareddata/xcschemes/Example-iOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Carbon.xcodeproj/xcshareddata/xcschemes/Carbon.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 42 | 48 | 49 | 50 | 51 | 52 | 62 | 63 | 69 | 70 | 71 | 72 | 78 | 79 | 85 | 86 | 87 | 88 | 90 | 91 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Form/FormTextViewContent.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Kyoto/KyotoMagazineLayoutAdapter.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Carbon 3 | import MagazineLayout 4 | 5 | extension MagazineLayoutCollectionViewCell: ComponentRenderable {} 6 | 7 | final class KyotoMagazineLayoutAdapter: UICollectionViewAdapter, UICollectionViewDelegateMagazineLayout { 8 | override func cellRegistration(collectionView: UICollectionView, indexPath: IndexPath, node: CellNode) -> CellRegistration { 9 | CellRegistration(class: MagazineLayoutCollectionViewCell.self) 10 | } 11 | 12 | override func supplementaryViewNode(forElementKind kind: String, collectionView: UICollectionView, at indexPath: IndexPath) -> ViewNode? { 13 | switch kind { 14 | case MagazineLayout.SupplementaryViewKind.sectionHeader: 15 | return headerNode(in: indexPath.section) 16 | 17 | case MagazineLayout.SupplementaryViewKind.sectionFooter: 18 | return footerNode(in: indexPath.section) 19 | 20 | default: 21 | return super.supplementaryViewNode(forElementKind: kind, collectionView: collectionView, at: indexPath) 22 | } 23 | } 24 | 25 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeModeForItemAt indexPath: IndexPath) -> MagazineLayoutItemSizeMode { 26 | let node = cellNode(at: indexPath) 27 | guard let size = node.component.referenceSize(in: collectionView.bounds) else { 28 | return MagazineLayoutItemSizeMode(widthMode: .halfWidth, heightMode: .dynamic) 29 | } 30 | 31 | return MagazineLayoutItemSizeMode(widthMode: .halfWidth, heightMode: .static(height: size.height)) 32 | } 33 | 34 | func collectionView( 35 | _ collectionView: UICollectionView, 36 | layout collectionViewLayout: UICollectionViewLayout, 37 | visibilityModeForHeaderInSectionAtIndex index: Int 38 | ) -> MagazineLayoutHeaderVisibilityMode { 39 | guard let node = headerNode(in: index) else { 40 | return .hidden 41 | } 42 | 43 | guard let referenceSize = node.component.referenceSize(in: collectionView.bounds) else { 44 | return .visible(heightMode: .dynamic) 45 | } 46 | 47 | return .visible(heightMode: .static(height: referenceSize.height)) 48 | } 49 | 50 | func collectionView( 51 | _ collectionView: UICollectionView, 52 | layout collectionViewLayout: UICollectionViewLayout, 53 | visibilityModeForFooterInSectionAtIndex index: Int 54 | ) -> MagazineLayoutFooterVisibilityMode { 55 | guard let node = footerNode(in: index) else { 56 | return .hidden 57 | } 58 | 59 | guard let referenceSize = node.component.referenceSize(in: collectionView.bounds) else { 60 | return .visible(heightMode: .dynamic) 61 | } 62 | 63 | return .visible(heightMode: .static(height: referenceSize.height)) 64 | } 65 | 66 | func collectionView( 67 | _ collectionView: UICollectionView, 68 | layout collectionViewLayout: UICollectionViewLayout, 69 | visibilityModeForBackgroundInSectionAtIndex index: Int 70 | ) -> MagazineLayoutBackgroundVisibilityMode { 71 | .hidden 72 | } 73 | 74 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, horizontalSpacingForItemsInSectionAtIndex index: Int) -> CGFloat { 75 | 16 76 | } 77 | 78 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, verticalSpacingForElementsInSectionAtIndex index: Int) -> CGFloat { 79 | 16 80 | } 81 | 82 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetsForSectionAtIndex index: Int) -> UIEdgeInsets { 83 | .zero 84 | } 85 | 86 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetsForItemsInSectionAtIndex index: Int) -> UIEdgeInsets { 87 | UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/Renderer.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Renderer is a controller to render passed data to target 4 | /// immediately using specific adapter and updater. 5 | /// 6 | /// Its behavior can be changed by using other type of adapter, 7 | /// updater, or by customizing it. 8 | /// 9 | /// Example for render a section containing simple nodes. 10 | /// 11 | /// let tableView: UITableView = ... 12 | /// let renderer = Renderer( 13 | /// adapter: UITableViewAdapter(), 14 | /// updater: UITableViewUpdater() 15 | /// ) 16 | /// 17 | /// renderer.target = tableView 18 | /// 19 | /// renderer.render { 20 | /// Label("Cell 1") 21 | /// .identified(by: \.text) 22 | /// 23 | /// Label("Cell 2") 24 | /// .identified(by: \.text) 25 | /// 26 | /// Label("Cell 3") 27 | /// .identified(by: \.text) 28 | /// } 29 | open class Renderer { 30 | /// An instance of adapter that specified at initialized. 31 | public let adapter: Updater.Adapter 32 | 33 | /// An instance of updater that specified at initialized. 34 | public let updater: Updater 35 | 36 | /// An instance of target that weakly referenced. 37 | /// It will be passed to the `prepare` method of updater at didSet. 38 | open weak var target: Updater.Target? { 39 | didSet { 40 | guard let target = target else { return } 41 | updater.prepare(target: target, adapter: adapter) 42 | } 43 | } 44 | 45 | /// Returns a current data held in adapter. 46 | /// When data is set, it renders to the target immediately. 47 | open var data: [Section] { 48 | get { return adapter.data } 49 | set(data) { render(data) } 50 | } 51 | 52 | /// Create a new instance with given adapter and updater. 53 | public init(adapter: Updater.Adapter, updater: Updater) { 54 | self.adapter = adapter 55 | self.updater = updater 56 | } 57 | 58 | /// Render given collection of sections, immediately. 59 | /// 60 | /// - Parameters: 61 | /// - data: A collection of sections to be rendered. 62 | open func render(_ data: C) where C.Element == Section { 63 | let data = Array(data) 64 | 65 | guard let target = target else { 66 | adapter.data = data 67 | return 68 | } 69 | 70 | updater.performUpdates(target: target, adapter: adapter, data: data) 71 | } 72 | 73 | /// Render given collection of sections skipping nil, immediately. 74 | /// 75 | /// - Parameters: 76 | /// - data: A collection of sections to be rendered that can be contains nil. 77 | open func render(_ data: C) where C.Element == Section? { 78 | render(data.compactMap { $0 }) 79 | } 80 | 81 | /// Render given collection sections, immediately. 82 | /// 83 | /// - Parameters: 84 | /// - data: A variadic number of sections to be rendered. 85 | open func render(_ data: Section...) { 86 | render(data) 87 | } 88 | 89 | /// Render given variadic number of sections skipping nil, immediately. 90 | /// 91 | /// - Parameters: 92 | /// - data: A variadic number of sections to be rendered that can be contains nil. 93 | open func render(_ data: Section?...) { 94 | render(data.compactMap { $0 }) 95 | } 96 | 97 | /// Render given variadic number of sections with function builder syntax, immediately. 98 | /// 99 | /// - Parameters: 100 | /// - sections: A closure that constructs sections. 101 | open func render(@SectionsBuilder sections: () -> S) { 102 | render(sections().buildSections()) 103 | } 104 | 105 | /// Render a single section contains given cells with function builder syntax, immediately. 106 | /// 107 | /// - Parameters: 108 | /// - cells: A closure that constructs cells. 109 | open func render(@CellsBuilder cells: () -> C) { 110 | render { 111 | Section(id: UniqueIdentifier(), cells: cells) 112 | } 113 | } 114 | } 115 | 116 | private struct UniqueIdentifier: Hashable {} 117 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Kyoto/KyotoImageContent.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 🚴 Contributing to Carbon 2 | 3 | First of all, thanks for your interest in Carbon. 4 | 5 | There are several ways to contribute to this project. We welcome contributions in all ways. 6 | We have made some contribution guidelines to smoothly incorporate your opinions and code into this project. 7 | 8 | ## 📝 Open Issue 9 | 10 | When you found a bug or having a feature request in Carbon, search for the issue from the [existing](https://github.com/ra1028/Carbon/issues) and feel free to open the issue after making sure it isn't already reported. 11 | 12 | In order to we understand your issue accurately, please include as much information as possible in the issue template. 13 | The screenshot are also big clue to understand the issue. 14 | 15 | If you know exactly how to fix the bug you report or implement the feature you propose, please pull request instead of an issue. 16 | 17 | ## 🚀 Pull Request 18 | 19 | We are waiting for a pull request to make this project more better with us. 20 | If you want to add a new feature, let's discuss about it first on issue. 21 | 22 | ### Getting Started 23 | 24 | To run project, install dependencies and open workspace as following commands. 25 | 26 | ```bash 27 | $ git clone https://github.com/ra1028/Carbon.git 28 | $ cd Carbon/ 29 | $ make setup 30 | $ open Carbon.xcworkspace 31 | ``` 32 | 33 | ### Lint 34 | 35 | Please introduce [SwiftLint](https://github.com/realm/SwiftLint) into your environment before start writing the code. 36 | Xcode automatically runs lint in the build phase. 37 | 38 | The code written according to lint should match our coding style, but for particular cases where style is unknown, refer to the existing code base. 39 | 40 | ### Test 41 | 42 | The test will tells us the validity of your code. 43 | All codes entering the master must pass the all tests. 44 | If you change the code or add new features, you should add tests. 45 | 46 | ### Documentation 47 | 48 | Please write the document using [Xcode markup](https://developer.apple.com/library/archive/documentation/Xcode/Reference/xcode_markup_formatting_ref/) to the code you added. 49 | Documentation template is inserted automatically by using Xcode shortcut **⌥⌘/**. 50 | Our document style is slightly different from the template. The example is below. 51 | ```swift 52 | /// The example class for documentation. 53 | final class Foo { 54 | /// A property value. 55 | let prop: Int 56 | 57 | /// Create a new foo with a param. 58 | /// 59 | /// - Parameters: 60 | /// - param: An Int value for prop. 61 | init(param: Int) { 62 | prop = param 63 | } 64 | 65 | /// Returns a string value concatenating `param1` and `param2`. 66 | /// 67 | /// - Parameters: 68 | /// - param1: An Int value for prefix. 69 | /// - param2: A String value for suffix. 70 | /// 71 | /// - Returns: A string concatenating given params. 72 | func bar(param1: Int, param2: String) -> String { 73 | return "\(param1)" + param2 74 | } 75 | } 76 | ``` 77 | 78 | ## [Developer's Certificate of Origin 1.1](https://elinux.org/Developer_Certificate_Of_Origin) 79 | By making a contribution to this project, I certify that: 80 | 81 | (a) The contribution was created in whole or in part by me and I 82 | have the right to submit it under the open source license 83 | indicated in the file; or 84 | 85 | (b) The contribution is based upon previous work that, to the best 86 | of my knowledge, is covered under an appropriate open source 87 | license and I have the right under that license to submit that 88 | work with modifications, whether created in whole or in part 89 | by me, under the same open source license (unless I am 90 | permitted to submit under a different license), as indicated 91 | in the file; or 92 | 93 | (c) The contribution was provided directly to me by some other 94 | person who certified (a), (b) or (c) and I have not modified 95 | it. 96 | 97 | (d) I understand and agree that this project and the contribution 98 | are public and that a record of the contribution (including all 99 | personal information I submit with it, including my sign-off) is 100 | maintained indefinitely and may be redistributed consistent with 101 | this project or the open source license(s) involved. 102 | -------------------------------------------------------------------------------- /Examples/Example-iOS/Sources/Form/FormTextFieldContent.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | --------------------------------------------------------------------------------