├── .gitignore ├── .travis.yml ├── ALLKit.podspec ├── Demos ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── arrow_right.imageset │ │ ├── Contents.json │ │ └── arrow_right.pdf │ ├── heart.imageset │ │ ├── Contents.json │ │ └── heart.pdf │ ├── setting.imageset │ │ ├── Contents.json │ │ └── setting.pdf │ ├── tail.imageset │ │ ├── Contents.json │ │ └── tail.pdf │ ├── trash.imageset │ │ ├── Contents.json │ │ └── trash.pdf │ └── watched.imageset │ │ ├── Contents.json │ │ └── watched.pdf └── Code │ ├── Animation │ ├── HorizontalSnippetLayoutSpec.swift │ ├── LayoutAnimationViewController.swift │ └── VerticalSnippetLayoutSpec.swift │ ├── AppDelegate.swift │ ├── Chat │ ├── ChatViewController.swift │ ├── IncomingTextMessageLayoutSpec.swift │ └── OutgoingTextMessageLayoutSpec.swift │ ├── Combine │ ├── CombinedLayoutViewController.swift │ ├── SwitcherListLayoutSpec.swift │ ├── SwitcherRowLayoutSpec.swift │ └── SwitcherRowSeparatorLayoutSpec.swift │ ├── Diff │ ├── AdapterControlsLayoutSpec.swift │ ├── AutoDiffViewController.swift │ └── NumberLayoutSpec.swift │ ├── Feed │ ├── FeedItem.swift │ ├── FeedItemLayoutSpec.swift │ ├── FeedItemSeparatorLayoutSpec.swift │ └── FeedViewController.swift │ ├── InRowList │ ├── GalleryItemLayoutSpec.swift │ ├── GalleryPhotoLayoutSpec.swift │ ├── GalleryView.swift │ └── MultiGalleriesViewController.swift │ ├── Mail │ ├── MailRowLayoutSpec.swift │ ├── MailRowSwipeActionLayoutSpec.swift │ └── MailViewController.swift │ ├── Movement │ └── MovementViewController.swift │ ├── Root │ ├── RootViewController.swift │ └── SelectableRowLayoutSpec.swift │ ├── SizeConstraints │ ├── EmojiLayoutSpec.swift │ └── SizeConstraintsDemoViewController.swift │ ├── Transition │ ├── LayoutTransitionViewController.swift │ └── ProfileLayoutSpec.swift │ ├── Utils │ ├── DemoContent.swift │ └── Extensions.swift │ └── Waterfall │ ├── WaterfallLayout.swift │ └── WaterfallViewController.swift ├── Docs ├── allkit_screens.png ├── animations.md ├── async_text.md ├── auto_diff.md ├── basic_concepts.md ├── hello_world.md ├── hello_world.png ├── list_view.md ├── string_builder.md ├── target_actions.md └── view_recycling.md ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Nuke.podspec ├── Podfile ├── Podfile.lock ├── README.md ├── Sources ├── Diff │ └── Diff.swift ├── Extended │ ├── AsyncLabel.swift │ ├── AttributedStringDrawing.swift │ ├── SwipeView.swift │ └── UIActions.swift ├── Layout │ ├── Calculator.swift │ ├── Dimensions.swift │ ├── FlexBox.swift │ ├── Layout.swift │ ├── Node.swift │ ├── SizeProvider.swift │ ├── Spec.swift │ └── ViewFactory.swift ├── ListKit │ ├── CollectionViewAdapter.swift │ ├── ListItem.swift │ ├── ListViewDataSource.swift │ └── Utils.swift ├── StringBuilder │ └── AttributedStringBuilder.swift └── Yoga │ └── Yoga.swift └── Tests ├── AsyncLabel+Tests.swift ├── BoundingSize+Tests.swift ├── CollectionViewAdapter+Tests.swift ├── Diff+Tests.swift ├── Layout+Tests.swift ├── ListItem+Tests.swift ├── TextBuilder+Tests.swift └── UIHelpers+Tests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | DerivedData/ 3 | *.pbxuser 4 | !default.pbxuser 5 | *.mode1v3 6 | !default.mode1v3 7 | *.mode2v3 8 | !default.mode2v3 9 | *.perspectivev3 10 | !default.perspectivev3 11 | xcuserdata/ 12 | *.moved-aside 13 | *.xccheckout 14 | *.xcscmblueprint 15 | *.hmap 16 | *.ipa 17 | *.dSYM.zip 18 | *.dSYM 19 | timeline.xctimeline 20 | playground.xcworkspace 21 | .build/ 22 | Pods/ 23 | Carthage/Build 24 | fastlane/report.xml 25 | fastlane/Preview.html 26 | fastlane/screenshots 27 | fastlane/test_output 28 | Sources/.DS_Store 29 | .DS_Store 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: osx 2 | osx_image: xcode11.3 3 | language: swift 4 | before_install: 5 | - gem update --system 6 | - gem install bundler 7 | script: 8 | - bundle install 9 | - bundle exec pod install 10 | - cd Pods 11 | - xcodebuild -scheme ALLKit -destination 'platform=iOS Simulator,OS=13.3,name=iPhone 11 Pro Max' build-for-testing test 12 | -------------------------------------------------------------------------------- /ALLKit.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'ALLKit' 3 | s.version = '1.4' 4 | s.summary = 'Async List Layout Kit' 5 | s.homepage = 'https://github.com/geor-kasapidi/ALLKit' 6 | s.license = { :type => 'MIT', :file => 'LICENSE' } 7 | s.author = { 'Georgy Kasapidi' => 'geor.kasapidi@icloud.com' } 8 | s.source = { :git => 'https://github.com/geor-kasapidi/ALLKit.git', :tag => "v#{s.version}" } 9 | s.platform = :ios, '9.0' 10 | s.swift_version = '5.1' 11 | s.requires_arc = true 12 | s.default_subspecs = 'StringBuilder', 'Extended' 13 | 14 | s.subspec 'Diff' do |ss| 15 | ss.source_files = 'Sources/Diff/*.swift' 16 | end 17 | 18 | s.subspec 'SwiftYoga' do |ss| 19 | ss.source_files = 'Sources/Yoga/*.swift' 20 | ss.frameworks = 'Foundation', 'UIKit' 21 | ss.library = 'c++' 22 | ss.dependency 'Yoga', '1.14' 23 | end 24 | 25 | s.subspec 'Layout' do |ss| 26 | ss.source_files = 'Sources/Layout/*.swift' 27 | ss.frameworks = 'Foundation', 'UIKit' 28 | ss.dependency 'ALLKit/SwiftYoga' 29 | end 30 | 31 | s.subspec 'ListKit' do |ss| 32 | ss.source_files = 'Sources/ListKit/*.swift' 33 | ss.frameworks = 'Foundation', 'UIKit' 34 | ss.dependency 'ALLKit/Layout' 35 | ss.dependency 'ALLKit/Diff' 36 | end 37 | 38 | s.subspec 'StringBuilder' do |ss| 39 | ss.source_files = 'Sources/StringBuilder/*.swift' 40 | ss.frameworks = 'Foundation', 'UIKit' 41 | end 42 | 43 | s.subspec 'Extended' do |ss| 44 | ss.source_files = 'Sources/Extended/*.swift' 45 | ss.frameworks = 'Foundation', 'UIKit' 46 | ss.dependency 'ALLKit/ListKit' 47 | end 48 | 49 | bundleId = 'n.seven.allkit' 50 | 51 | s.app_spec 'Demos' do |as| 52 | as.ios.deployment_target = '10.0' 53 | as.source_files = 'Demos/**/*.swift' 54 | as.resources = 'Demos/Assets.xcassets' 55 | as.info_plist = { 56 | 'CFBundleIdentifier' => bundleId, 57 | 'UIUserInterfaceStyle' => 'Light' 58 | } 59 | as.pod_target_xcconfig = { 60 | 'PRODUCT_BUNDLE_IDENTIFIER' => bundleId 61 | } 62 | as.dependency 'PinIt' 63 | as.dependency 'Nuke' 64 | end 65 | 66 | s.test_spec 'Tests' do |ts| 67 | ts.requires_app_host = true 68 | ts.test_type = :unit 69 | ts.source_files = 'Tests/**/*.swift' 70 | ts.app_host_name = 'ALLKit/Demos' 71 | ts.dependency 'ALLKit/Demos' 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /Demos/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /Demos/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Demos/Assets.xcassets/arrow_right.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "arrow_right.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Demos/Assets.xcassets/arrow_right.imageset/arrow_right.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geor-kasapidi/ALLKit/a56dee5d4fae6acadd644afbb70f23b84ea6e073/Demos/Assets.xcassets/arrow_right.imageset/arrow_right.pdf -------------------------------------------------------------------------------- /Demos/Assets.xcassets/heart.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "heart.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Demos/Assets.xcassets/heart.imageset/heart.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geor-kasapidi/ALLKit/a56dee5d4fae6acadd644afbb70f23b84ea6e073/Demos/Assets.xcassets/heart.imageset/heart.pdf -------------------------------------------------------------------------------- /Demos/Assets.xcassets/setting.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "setting.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Demos/Assets.xcassets/setting.imageset/setting.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geor-kasapidi/ALLKit/a56dee5d4fae6acadd644afbb70f23b84ea6e073/Demos/Assets.xcassets/setting.imageset/setting.pdf -------------------------------------------------------------------------------- /Demos/Assets.xcassets/tail.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "tail.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Demos/Assets.xcassets/tail.imageset/tail.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geor-kasapidi/ALLKit/a56dee5d4fae6acadd644afbb70f23b84ea6e073/Demos/Assets.xcassets/tail.imageset/tail.pdf -------------------------------------------------------------------------------- /Demos/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 | } -------------------------------------------------------------------------------- /Demos/Assets.xcassets/trash.imageset/trash.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geor-kasapidi/ALLKit/a56dee5d4fae6acadd644afbb70f23b84ea6e073/Demos/Assets.xcassets/trash.imageset/trash.pdf -------------------------------------------------------------------------------- /Demos/Assets.xcassets/watched.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "watched.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Demos/Assets.xcassets/watched.imageset/watched.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geor-kasapidi/ALLKit/a56dee5d4fae6acadd644afbb70f23b84ea6e073/Demos/Assets.xcassets/watched.imageset/watched.pdf -------------------------------------------------------------------------------- /Demos/Code/Animation/HorizontalSnippetLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | import Nuke 5 | 6 | final class HorizontalSnippetLayoutSpec: ModelLayoutSpec { 7 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 8 | let titleText = model.title.attributed() 9 | .font(UIFont.boldSystemFont(ofSize: 18)) 10 | .foregroundColor(#colorLiteral(red: 0.2549019754, green: 0.2745098174, blue: 0.3019607961, alpha: 1)) 11 | .make() 12 | 13 | let subtitleText = model.subtitle.attributed() 14 | .font(UIFont.systemFont(ofSize: 14)) 15 | .foregroundColor(#colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1)) 16 | .make() 17 | 18 | let titleNode = LayoutNode(sizeProvider: titleText, { 19 | $0.alignItems(.center).justifyContent(.center).flex(1) 20 | }) { (label: UILabel, isNew) in 21 | if isNew { 22 | label.numberOfLines = 0 23 | label.attributedText = titleText 24 | } 25 | } 26 | 27 | let subtitleNode = LayoutNode(sizeProvider: subtitleText) { (label: UILabel, isNew) in 28 | if isNew { 29 | label.numberOfLines = 0 30 | label.attributedText = subtitleText 31 | } 32 | } 33 | 34 | let imageNode = LayoutNode({ 35 | $0.width(80).height(80).margin(.right(16)) 36 | }) { (imageView: UIImageView, isNew) in 37 | imageView.layer.cornerRadius = 40 38 | 39 | if isNew { 40 | imageView.contentMode = .scaleAspectFill 41 | imageView.backgroundColor = #colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1) 42 | imageView.layer.masksToBounds = true 43 | 44 | self.model.url.flatMap { 45 | _ = Nuke.loadImage( 46 | with: $0, 47 | options: ImageLoadingOptions(transition: .fadeIn(duration: 0.33)), 48 | into: imageView 49 | ) 50 | } 51 | } 52 | } 53 | 54 | let topStackNode = LayoutNode(children: [imageNode, titleNode], { 55 | $0.flexDirection(.row).alignItems(.center).margin(.bottom(16)) 56 | }) 57 | 58 | let mainStackNode = LayoutNode(children: [topStackNode, subtitleNode], { 59 | $0.padding(.all(16)).flexDirection(.column) 60 | }) 61 | 62 | return mainStackNode 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Demos/Code/Animation/LayoutAnimationViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | struct AnimationDemoModel { 6 | let url: URL? 7 | let title: String 8 | let subtitle: String 9 | } 10 | 11 | final class LayoutAnimationViewController: UIViewController { 12 | private let model = AnimationDemoModel( 13 | url: URL(string: "https://picsum.photos/640/480?random&q=\(Int.random(in: 1..<1000))"), 14 | title: "Lorem Ipsum", 15 | subtitle: DemoContent.loremIpsum.joined(separator: " ") 16 | ) 17 | 18 | private lazy var scrollView = UIScrollView() 19 | private lazy var contentView = UIView() 20 | 21 | private lazy var horizontalLayoutSpec = HorizontalSnippetLayoutSpec(model: model) 22 | private lazy var verticalLayoutSpec = VerticalSnippetLayoutSpec(model: model) 23 | 24 | private var currentLayoutSpec: LayoutSpec? = nil { 25 | didSet { 26 | view.setNeedsLayout() 27 | } 28 | } 29 | 30 | private var isHorizontal: Bool = false { 31 | didSet { 32 | guard isHorizontal != oldValue else { return } 33 | 34 | currentLayoutSpec = isHorizontal ? horizontalLayoutSpec : verticalLayoutSpec 35 | } 36 | } 37 | 38 | override func viewDidLoad() { 39 | super.viewDidLoad() 40 | 41 | do { 42 | view.backgroundColor = UIColor.white 43 | 44 | view.addSubview(scrollView) 45 | 46 | scrollView.addSubview(contentView) 47 | } 48 | 49 | navigationItem.rightBarButtonItem = UIBarButtonItem( 50 | title: "Change layout", 51 | style: .plain, 52 | target: self, 53 | action: #selector(changeLayout) 54 | ) 55 | 56 | isHorizontal = true 57 | } 58 | 59 | override func viewWillLayoutSubviews() { 60 | super.viewWillLayoutSubviews() 61 | 62 | scrollView.frame = view.bounds 63 | 64 | guard let layoutSpec = currentLayoutSpec else { 65 | return 66 | } 67 | 68 | let layout = layoutSpec.makeLayoutWith(boundingDimensions: view.bounds.size.layoutDimensions) 69 | 70 | scrollView.contentSize = layout.size 71 | 72 | layout.setup(in: contentView) 73 | } 74 | 75 | @objc 76 | private func changeLayout() { 77 | UIView.animate(withDuration: 0.5) { [weak self] in 78 | self?.toggleLayout() 79 | } 80 | } 81 | 82 | private func toggleLayout() { 83 | isHorizontal = !isHorizontal 84 | 85 | view.layoutIfNeeded() 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Demos/Code/Animation/VerticalSnippetLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | import Nuke 5 | 6 | final class VerticalSnippetLayoutSpec: ModelLayoutSpec { 7 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 8 | let titleText = model.title.attributed() 9 | .font(UIFont.boldSystemFont(ofSize: 18)) 10 | .foregroundColor(#colorLiteral(red: 0.2549019754, green: 0.2745098174, blue: 0.3019607961, alpha: 1)) 11 | .make() 12 | 13 | let subtitleText = model.subtitle.attributed() 14 | .font(UIFont.systemFont(ofSize: 14)) 15 | .foregroundColor(#colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1)) 16 | .make() 17 | 18 | let titleNode = LayoutNode(sizeProvider: titleText, { 19 | $0.margin(.all(16)) 20 | }) { (label: UILabel, isNew) in 21 | if isNew { 22 | label.numberOfLines = 0 23 | label.attributedText = titleText 24 | } 25 | } 26 | 27 | let subtitleNode = LayoutNode(sizeProvider: subtitleText, { 28 | $0.margin(.all(16)) 29 | }) { (label: UILabel, isNew) in 30 | if isNew { 31 | label.numberOfLines = 0 32 | label.attributedText = subtitleText 33 | } 34 | } 35 | 36 | let imageNode = LayoutNode({ 37 | $0.width(.percent(100)).height(200).margin(.bottom(16)) 38 | }) { (imageView: UIImageView, isNew) in 39 | imageView.layer.cornerRadius = 0 40 | 41 | if isNew { 42 | imageView.contentMode = .scaleAspectFill 43 | imageView.backgroundColor = #colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1) 44 | imageView.layer.masksToBounds = true 45 | 46 | self.model.url.flatMap { 47 | _ = Nuke.loadImage( 48 | with: $0, 49 | options: ImageLoadingOptions(transition: .fadeIn(duration: 0.33)), 50 | into: imageView 51 | ) 52 | } 53 | } 54 | } 55 | 56 | let mainStackNode = LayoutNode(children: [imageNode, titleNode, subtitleNode], { 57 | $0.flexDirection(.column).alignItems(.center) 58 | }) 59 | 60 | return mainStackNode 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Demos/Code/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import PinIt 3 | 4 | @UIApplicationMain 5 | class AppDelegate: UIResponder, UIApplicationDelegate { 6 | var window: UIWindow? 7 | 8 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 9 | 10 | let nc = UINavigationController(rootViewController: RootViewController()) 11 | nc.navigationBar.isTranslucent = false 12 | 13 | let wnd = UIWindow() 14 | 15 | if #available(iOS 11.0, *) { 16 | let vc = UIViewController() 17 | vc.view.backgroundColor = UIColor.white 18 | 19 | vc.addChild(nc) 20 | vc.view.addSubview(nc.view) 21 | nc.view.pinEdges(to: vc.view.safeAreaLayoutItem) 22 | nc.didMove(toParent: vc) 23 | 24 | wnd.rootViewController = vc 25 | } else { 26 | wnd.rootViewController = nc 27 | } 28 | 29 | window = wnd 30 | 31 | wnd.makeKeyAndVisible() 32 | 33 | return true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Demos/Code/Chat/ChatViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | struct ChatColors { 6 | static let incoming = #colorLiteral(red: 0.9214877486, green: 0.9216202497, blue: 0.9214588404, alpha: 1) 7 | static let outgoing = #colorLiteral(red: 0.3411764801, green: 0.6235294342, blue: 0.1686274558, alpha: 1) 8 | } 9 | 10 | struct ChatMessage: Hashable { 11 | let id = UUID().uuidString 12 | let text: String 13 | let date: Date 14 | } 15 | 16 | final class ChatViewController: ListViewController { 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | 20 | do { 21 | if #available(iOS 11.0, *) { 22 | adapter.collectionView.contentInsetAdjustmentBehavior = .never 23 | } else { 24 | automaticallyAdjustsScrollViewInsets = false 25 | } 26 | 27 | adapter.collectionView.transform = CGAffineTransform(rotationAngle: CGFloat.pi) 28 | } 29 | 30 | do { 31 | DispatchQueue.global().async { 32 | let items = self.generateItems() 33 | 34 | DispatchQueue.main.async { 35 | self.adapter.set(items: items) 36 | } 37 | } 38 | } 39 | } 40 | 41 | override func viewWillLayoutSubviews() { 42 | super.viewWillLayoutSubviews() 43 | 44 | adapter.set( 45 | boundingDimensions: CGSize( 46 | width: view.bounds.width, 47 | height: .nan 48 | ).layoutDimensions 49 | ) 50 | } 51 | 52 | private func generateItems() -> [ListItem] { 53 | let sentences = DemoContent.loremIpsum 54 | let emodji = DemoContent.emodjiString 55 | 56 | return (0..<100).flatMap { n -> [ListItem] in 57 | let firstMessageItem: ListItem 58 | 59 | do { 60 | let text = sentences.randomElement()! 61 | let message = ChatMessage(text: text, date: Date()) 62 | 63 | if n % 2 == 0 { 64 | firstMessageItem = ListItem( 65 | id: message, 66 | layoutSpec: IncomingTextMessageLayoutSpec(model: message) 67 | ) 68 | } else { 69 | firstMessageItem = ListItem( 70 | id: message, 71 | layoutSpec: OutgoingTextMessageLayoutSpec(model: message) 72 | ) 73 | } 74 | } 75 | 76 | let secondMessageItem: ListItem 77 | 78 | do { 79 | let text = String(emodji.prefix(Int.random(in: 1.. { 7 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 8 | let attributedText = model.text.attributed() 9 | .font(.systemFont(ofSize: 14)) 10 | .foregroundColor(UIColor.black) 11 | .make() 12 | .drawing() 13 | 14 | let attributedDateText = DateFormatter.localizedString(from: model.date, dateStyle: .none, timeStyle: DateFormatter.Style.short).attributed() 15 | .font(.italicSystemFont(ofSize: 10)) 16 | .foregroundColor(UIColor.black.withAlphaComponent(0.6)) 17 | .make() 18 | 19 | let tailNode = LayoutNode({ 20 | $0.isOverlay(true).width(20).height(18).position(.bottom(0), .left(-6)) 21 | }) { (imageView: UIImageView, _) in 22 | imageView.superview?.sendSubviewToBack(imageView) 23 | imageView.image = UIImage(named: "tail")?.withRenderingMode(.alwaysTemplate) 24 | imageView.tintColor = ChatColors.incoming 25 | imageView.transform = CGAffineTransform(scaleX: -1, y: 1) 26 | } 27 | 28 | let textNode = LayoutNode(sizeProvider: attributedText, { 29 | $0.flexShrink(1).margin(.right(8)) 30 | }) { (label: AsyncLabel, _) in 31 | label.stringDrawing = attributedText 32 | } 33 | 34 | let dateNode = LayoutNode(sizeProvider: attributedDateText) { (label: UILabel, _) in 35 | label.attributedText = attributedDateText 36 | } 37 | 38 | let infoNode = LayoutNode(children: [dateNode], { 39 | $0.margin(.bottom(2)).flexDirection(.row).alignItems(.center).alignSelf(.flexEnd) 40 | }) 41 | 42 | let backgroundNode = LayoutNode(children: [textNode, tailNode, infoNode], { 43 | $0.flexDirection(.row).alignItems(.center).padding(.vertical(8), .horizontal(12)).margin(.left(16), .bottom(8), .right(.percent(30))).min(.height(36)) 44 | }) { (view: UIView, _) in 45 | view.backgroundColor = ChatColors.incoming 46 | view.layer.cornerRadius = 18 47 | } 48 | 49 | return LayoutNode(children: [backgroundNode], { 50 | $0.alignItems(.center).flexDirection(.row) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Demos/Code/Chat/OutgoingTextMessageLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class OutgoingTextMessageLayoutSpec: ModelLayoutSpec { 6 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 7 | let attributedText = model.text.attributed() 8 | .font(.systemFont(ofSize: 14)) 9 | .foregroundColor(UIColor.white) 10 | .make() 11 | .drawing() 12 | 13 | let attributedDateText = DateFormatter.localizedString(from: model.date, dateStyle: .none, timeStyle: DateFormatter.Style.short).attributed() 14 | .font(.italicSystemFont(ofSize: 10)) 15 | .foregroundColor(UIColor.white) 16 | .make() 17 | 18 | let tailNode = LayoutNode({ 19 | $0.isOverlay(true).width(20).height(20).position(.bottom(0), .right(-6)) 20 | }) { (imageView: UIImageView, _) in 21 | imageView.superview?.sendSubviewToBack(imageView) 22 | imageView.image = UIImage(named: "tail")?.withRenderingMode(.alwaysTemplate) 23 | imageView.tintColor = ChatColors.outgoing 24 | } 25 | 26 | let textNode = LayoutNode(sizeProvider: attributedText, { 27 | $0.flexShrink(1).margin(.right(8)) 28 | }) { (label: AsyncLabel, _) in 29 | label.stringDrawing = attributedText 30 | } 31 | 32 | let dateNode = LayoutNode(sizeProvider: attributedDateText) { (label: UILabel, _) in 33 | label.attributedText = attributedDateText 34 | } 35 | 36 | let infoNode = LayoutNode(children: [dateNode], { 37 | $0.margin(.bottom(2)).flexDirection(.row).alignItems(.center).alignSelf(.flexEnd) 38 | }) 39 | 40 | let backgroundNode = LayoutNode(children: [tailNode, textNode, infoNode], { 41 | $0.flexDirection(.row).alignItems(.center).padding(.top(8), .left(16), .bottom(8), .right(8)).margin(.left(.percent(30)), .bottom(8), .right(16)).min(.height(36)) 42 | }) { (view: UIView, _) in 43 | view.backgroundColor = ChatColors.outgoing 44 | view.layer.cornerRadius = 18 45 | } 46 | 47 | return LayoutNode(children: [backgroundNode], { 48 | $0.alignItems(.center).flexDirection(.rowReverse) 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Demos/Code/Combine/CombinedLayoutViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class CombinedLayoutViewController: UIViewController { 6 | private lazy var scrollView = UIScrollView() 7 | private lazy var contentView = UIView() 8 | private lazy var layoutSpec = SwitcherListLayoutSpec(model: DemoContent.NATO) 9 | 10 | override func viewDidLoad() { 11 | super.viewDidLoad() 12 | 13 | do { 14 | view.backgroundColor = UIColor.white 15 | 16 | view.addSubview(scrollView) 17 | 18 | scrollView.addSubview(contentView) 19 | } 20 | } 21 | 22 | override func viewWillLayoutSubviews() { 23 | super.viewWillLayoutSubviews() 24 | 25 | scrollView.frame = view.bounds 26 | 27 | let layout = layoutSpec.makeLayoutWith(boundingDimensions: CGSize(width: view.bounds.width, height: .nan).layoutDimensions) 28 | 29 | scrollView.contentSize = layout.size 30 | layout.setup(in: contentView) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Demos/Code/Combine/SwitcherListLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class SwitcherListLayoutSpec: ModelLayoutSpec<[String]> { 6 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 7 | let children = model.flatMap({ title -> [LayoutNodeConvertible] in 8 | let switcherNode = SwitcherRowLayoutSpec(model: title).makeNodeWith(boundingDimensions: boundingDimensions) 9 | 10 | let separatorNode = SwitcherRowSeparatorLayoutSpec().makeNodeWith(boundingDimensions: boundingDimensions) 11 | 12 | return [switcherNode, separatorNode] 13 | }) 14 | 15 | return LayoutNodeBuilder().layout { 16 | $0.flexDirection(.column) 17 | }.body { 18 | children 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demos/Code/Combine/SwitcherRowLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class SwitcherRowLayoutSpec: ModelLayoutSpec { 6 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 7 | let attributedTitle = model.attributed() 8 | .font(.systemFont(ofSize: 14)) 9 | .foregroundColor(#colorLiteral(red: 0, green: 0, blue: 0, alpha: 1)) 10 | .make() 11 | 12 | return LayoutNodeBuilder().layout().body { 13 | LayoutNodeBuilder().layout { 14 | $0 15 | .flexDirection(.row) 16 | .alignItems(.center) 17 | .justifyContent(.spaceBetween) 18 | .margin(.vertical(12), .horizontal(16)) 19 | }.body { 20 | LayoutNode(sizeProvider: attributedTitle) { (label: UILabel, isNew) in 21 | if isNew { 22 | label.numberOfLines = 0 23 | label.attributedText = attributedTitle 24 | } 25 | } 26 | LayoutNodeBuilder().layout { 27 | $0.width(51).height(32).margin(.left(8)) 28 | }.view { (switcher: UISwitch, isNew) in 29 | if isNew { 30 | switcher.onTintColor = #colorLiteral(red: 0.1764705926, green: 0.4980392158, blue: 0.7568627596, alpha: 1) 31 | switcher.setOn(true, animated: false) 32 | switcher.all_setEventHandler(for: .valueChanged, { [weak switcher] in 33 | 34 | guard let switcher = switcher, let index = switcher.superview?.subviews.firstIndex(of: switcher) else { 35 | return 36 | } 37 | switcher.superview?.subviews[index - 1].alpha = switcher.isOn ? 1 : 0.3 38 | }) 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Demos/Code/Combine/SwitcherRowSeparatorLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | import yoga 5 | 6 | final class SwitcherRowSeparatorLayoutSpec: LayoutSpec { 7 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 8 | let viewNode = LayoutNode({ 9 | $0.height(.point(1.0/UIScreen.main.scale)) 10 | }) { (view: UIView, isNew) in 11 | if isNew { 12 | view.backgroundColor = #colorLiteral(red: 0.6000000238, green: 0.6000000238, blue: 0.6000000238, alpha: 1) 13 | } 14 | } 15 | 16 | return LayoutNode(children: [viewNode], { 17 | $0.padding(.left(16)) 18 | }) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Demos/Code/Diff/AdapterControlsLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | struct AdapterControlsModel { 6 | let delayChanged: (TimeInterval) -> Void 7 | let movesEnabledChanged: (Bool) -> Void 8 | } 9 | 10 | final class AdapterControlsLayoutSpec: ModelLayoutSpec { 11 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 12 | let sliderTitle = "Reload delay".attributed() 13 | .font(.systemFont(ofSize: 8)) 14 | .foregroundColor(UIColor.black) 15 | .make() 16 | 17 | let switcherTitle = "Moves enabled".attributed() 18 | .font(.systemFont(ofSize: 8)) 19 | .foregroundColor(UIColor.black) 20 | .make() 21 | 22 | let sliderTitleNode = LayoutNode(sizeProvider: sliderTitle, { 23 | $0.width(40) 24 | }) { (label: UILabel, _) in 25 | label.numberOfLines = 0 26 | label.attributedText = sliderTitle 27 | } 28 | 29 | let sliderNode = LayoutNode({ 30 | $0.margin(.left(8)).height(24).width(80) 31 | }) { (slider: UISlider, _) in 32 | slider.minimumValue = 0.05 33 | slider.maximumValue = 2.0 34 | slider.value = 0.5 35 | 36 | slider.all_setEventHandler(for: .valueChanged, { [weak slider] in 37 | slider.flatMap { self.model.delayChanged(TimeInterval($0.value)) } 38 | }) 39 | } 40 | 41 | guard #available(iOS 11.0, *) else { 42 | return LayoutNode(children: [sliderTitleNode, sliderNode], { 43 | $0.flexDirection(.row).alignItems(.center) 44 | }) 45 | } 46 | 47 | let swicherTitleNode = LayoutNode(sizeProvider: switcherTitle, { 48 | $0.margin(.left(40)).width(40) 49 | }) { (label: UILabel, _) in 50 | label.numberOfLines = 0 51 | label.attributedText = switcherTitle 52 | } 53 | 54 | let switcherNode = LayoutNode({ 55 | $0.margin(.left(8)).width(51).height(32) 56 | }) { (switcher: UISwitch, _) in 57 | switcher.setOn(true, animated: false) 58 | 59 | switcher.all_setEventHandler(for: .valueChanged, { [weak switcher] in 60 | switcher.flatMap { self.model.movesEnabledChanged($0.isOn) } 61 | }) 62 | } 63 | 64 | return LayoutNode(children: [sliderTitleNode, sliderNode, swicherTitleNode, switcherNode], { 65 | $0.flexDirection(.row).alignItems(.center) 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Demos/Code/Diff/AutoDiffViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class AutoDiffViewController: ListViewController { 6 | private struct Consts { 7 | static let spacing: CGFloat = 4 8 | } 9 | 10 | init() { 11 | super.init(adapter: CollectionViewAdapter( 12 | scrollDirection: .vertical, 13 | sectionInset: UIEdgeInsets(top: Consts.spacing, left: Consts.spacing, bottom: Consts.spacing, right: Consts.spacing), 14 | minimumLineSpacing: Consts.spacing, 15 | minimumInteritemSpacing: Consts.spacing 16 | )) 17 | } 18 | 19 | required init?(coder: NSCoder) { 20 | fatalError() 21 | } 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | 26 | do { 27 | let model = AdapterControlsModel( 28 | delayChanged: { [weak self] delay in 29 | self?.delay = delay 30 | }, 31 | movesEnabledChanged: { [weak self] movesEnabled in 32 | self?.adapter.settings.allowMovesInBatchUpdates = movesEnabled 33 | } 34 | ) 35 | 36 | let controlsView = AdapterControlsLayoutSpec(model: model) 37 | .makeLayoutWith(boundingDimensions: CGSize(width: 300, height: 40).layoutDimensions) 38 | .makeView() 39 | 40 | setToolbarItems([UIBarButtonItem(customView: controlsView)], animated: false) 41 | } 42 | 43 | generateItems() 44 | } 45 | 46 | override func viewWillAppear(_ animated: Bool) { 47 | super.viewWillAppear(animated) 48 | 49 | navigationController?.setToolbarHidden(false, animated: animated) 50 | } 51 | 52 | override func viewWillLayoutSubviews() { 53 | super.viewWillLayoutSubviews() 54 | 55 | let numberOfColumns = 4 56 | 57 | let size = (view.bounds.width - CGFloat(numberOfColumns + 1) * Consts.spacing) / CGFloat(numberOfColumns) - 1 58 | 59 | adapter.set(boundingDimensions: CGSize(width: size, height: size).layoutDimensions) 60 | } 61 | 62 | private func generateItems() { 63 | let numbers = (0..<100).map { _ in Int(arc4random_uniform(100)) } 64 | 65 | let items = numbers.map { number -> ListItem in 66 | let item = ListItem( 67 | id: number, 68 | layoutSpec: NumberLayoutSpec(model: number) 69 | ) 70 | 71 | item.context = DemoContext( 72 | willDisplay: { _ in 73 | print("👆🏻", number) 74 | }, 75 | didEndDisplaying: { _ in 76 | print("👇🏻", number) 77 | } 78 | ) 79 | 80 | return item 81 | } 82 | 83 | adapter.set(items: items) 84 | 85 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in 86 | self?.generateItems() 87 | } 88 | } 89 | 90 | override func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { 91 | adapter.contextForItem(at: indexPath.item)?.willDisplay?(cell.contentView) 92 | } 93 | 94 | override func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { 95 | adapter.contextForItem(at: indexPath.item)?.didEndDisplaying?(cell.contentView) 96 | } 97 | 98 | private var delay: TimeInterval = 0.5 99 | } 100 | -------------------------------------------------------------------------------- /Demos/Code/Diff/NumberLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class NumberLayoutSpec: ModelLayoutSpec { 6 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 7 | let attributedText = String(model).attributed() 8 | .font(.boldSystemFont(ofSize: 36)) 9 | .foregroundColor(UIColor.white) 10 | .make() 11 | 12 | return LayoutNodeBuilder().layout { 13 | $0.alignItems(.center).justifyContent(.center) 14 | }.view { (view: UIView, _) in 15 | view.layer.cornerRadius = 4 16 | view.backgroundColor = #colorLiteral(red: 0.1764705926, green: 0.4980392158, blue: 0.7568627596, alpha: 1) 17 | }.body { 18 | LayoutNode(sizeProvider: attributedText) { (label: UILabel, _) in 19 | label.attributedText = attributedText 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Demos/Code/Feed/FeedItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct FeedItem: Equatable { 4 | let id = UUID().uuidString 5 | let avatar: URL? 6 | let title: String 7 | let date: Date 8 | let image: URL? 9 | let likesCount: UInt 10 | let viewsCount: UInt 11 | 12 | static func ==(lhs: FeedItem, rhs: FeedItem) -> Bool { 13 | return lhs.id == rhs.id 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Demos/Code/Feed/FeedItemLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | import Nuke 5 | 6 | final class FeedItemLayoutSpec: ModelLayoutSpec { 7 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 8 | let attributedTitleText = model.title.attributed() 9 | .font(.boldSystemFont(ofSize: 16)) 10 | .foregroundColor(#colorLiteral(red: 0.1411764771, green: 0.3960784376, blue: 0.5647059083, alpha: 1)) 11 | .make() 12 | 13 | let attributedDateText = DateFormatter.localizedString(from: model.date, dateStyle: .medium, timeStyle: .short).attributed() 14 | .font(.systemFont(ofSize: 12)) 15 | .foregroundColor(#colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1)) 16 | .make() 17 | 18 | let attributedLikesCountText = String(model.likesCount).attributed() 19 | .font(.systemFont(ofSize: 14)) 20 | .foregroundColor(#colorLiteral(red: 0.6000000238, green: 0.6000000238, blue: 0.6000000238, alpha: 1)) 21 | .make() 22 | 23 | let attributedViewsCountText = String(model.viewsCount).attributed() 24 | .font(.systemFont(ofSize: 14)) 25 | .foregroundColor(#colorLiteral(red: 0.6000000238, green: 0.6000000238, blue: 0.6000000238, alpha: 1)) 26 | .make() 27 | 28 | return LayoutNodeBuilder().layout { 29 | $0.flexDirection(.column) 30 | }.body { 31 | // top panel 32 | LayoutNodeBuilder().layout { 33 | $0.flexDirection(.row).alignItems(.center).padding(.top(8), .left(16), .bottom(8), .right(16)) 34 | }.body { 35 | LayoutNodeBuilder().layout { 36 | $0.width(40).height(40).margin(.right(16)) 37 | }.view { (imageView: UIImageView, _) in 38 | imageView.contentMode = .scaleAspectFill 39 | imageView.backgroundColor = #colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1) 40 | imageView.layer.cornerRadius = 20 41 | imageView.layer.masksToBounds = true 42 | 43 | self.model.avatar.flatMap { 44 | _ = Nuke.loadImage( 45 | with: $0, 46 | options: ImageLoadingOptions(transition: .fadeIn(duration: 0.33)), 47 | into: imageView 48 | ) 49 | } 50 | } 51 | LayoutNodeBuilder().layout { 52 | $0.flexDirection(.column).flex(1) 53 | }.body { 54 | LayoutNodeBuilder().layout(sizeProvider: attributedTitleText) { 55 | $0.margin(.bottom(2)) 56 | }.view { (label: UILabel, _) in 57 | label.numberOfLines = 0 58 | label.attributedText = attributedTitleText 59 | } 60 | LayoutNode(sizeProvider: attributedDateText) { (label: UILabel, _) in 61 | label.attributedText = attributedDateText 62 | } 63 | } 64 | } 65 | // main image 66 | LayoutNodeBuilder().layout { 67 | $0.aspectRatio(1) 68 | }.view { (imageView: UIImageView, _) in 69 | imageView.contentMode = .scaleAspectFill 70 | imageView.backgroundColor = #colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1) 71 | 72 | self.model.image.flatMap { 73 | _ = Nuke.loadImage( 74 | with: $0, 75 | options: ImageLoadingOptions(transition: .fadeIn(duration: 0.33)), 76 | into: imageView 77 | ) 78 | } 79 | } 80 | // bottom panel 81 | LayoutNodeBuilder().layout { 82 | $0.flexDirection(.row).justifyContent(.spaceBetween).padding(.vertical(8), .horizontal(32)) 83 | }.body { 84 | LayoutNodeBuilder().layout { 85 | $0.flexDirection(.row).alignItems(.center) 86 | }.body { 87 | LayoutNodeBuilder().layout { 88 | $0.width(24).height(24).margin(.right(4)) 89 | }.view { (imageView: UIImageView, _) in 90 | imageView.contentMode = .scaleAspectFit 91 | imageView.tintColor = #colorLiteral(red: 0.6000000238, green: 0.6000000238, blue: 0.6000000238, alpha: 1) 92 | imageView.image = UIImage(named: "heart")?.withRenderingMode(.alwaysTemplate) 93 | } 94 | LayoutNode(sizeProvider: attributedLikesCountText) { (label: UILabel, _) in 95 | label.attributedText = attributedLikesCountText 96 | } 97 | } 98 | LayoutNodeBuilder().layout { 99 | $0.flexDirection(.row).alignItems(.center) 100 | }.body { 101 | LayoutNodeBuilder().layout { 102 | $0.width(24).height(24).margin(.right(4)) 103 | }.view { (imageView: UIImageView, _) in 104 | imageView.contentMode = .scaleAspectFit 105 | imageView.tintColor = #colorLiteral(red: 0.6000000238, green: 0.6000000238, blue: 0.6000000238, alpha: 1) 106 | imageView.image = UIImage(named: "watched")?.withRenderingMode(.alwaysTemplate) 107 | } 108 | LayoutNode(sizeProvider: attributedViewsCountText) { (label: UILabel, _) in 109 | label.attributedText = attributedViewsCountText 110 | } 111 | } 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Demos/Code/Feed/FeedItemSeparatorLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | import yoga 5 | 6 | final class FeedItemSeparatorLayoutSpec: LayoutSpec { 7 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 8 | LayoutNodeBuilder().layout().body { 9 | LayoutNodeBuilder().layout { 10 | $0.flexDirection(.column).justifyContent(.spaceBetween).height(8) 11 | }.view { (view: UIView, _) in 12 | view.backgroundColor = #colorLiteral(red: 0.9398509844, green: 0.9398509844, blue: 0.9398509844, alpha: 1) 13 | }.body { 14 | LayoutNodeBuilder().layout { 15 | $0.height(.point(1.0/UIScreen.main.scale)) 16 | }.view { (view: UIView, _) in 17 | view.backgroundColor = #colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1) 18 | } 19 | LayoutNodeBuilder().layout { 20 | $0.height(.point(1.0/UIScreen.main.scale)) 21 | }.view { (view: UIView, _) in 22 | view.backgroundColor = #colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1) 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Demos/Code/Feed/FeedViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class FeedViewController: ListViewController { 6 | override func viewDidLoad() { 7 | super.viewDidLoad() 8 | 9 | DispatchQueue.global().async { 10 | let items = self.generateItems() 11 | 12 | DispatchQueue.main.async { 13 | self.adapter.set(items: items) 14 | } 15 | } 16 | } 17 | 18 | override func viewWillLayoutSubviews() { 19 | super.viewWillLayoutSubviews() 20 | 21 | adapter.set(boundingDimensions: CGSize(width: view.bounds.width, height: .nan).layoutDimensions) 22 | } 23 | 24 | private func generateItems() -> [ListItem] { 25 | return (0..<100).flatMap { _ -> [ListItem] in 26 | let item = FeedItem( 27 | avatar: URL(string: "https://picsum.photos/100/100?random&q=\(Int.random(in: 1..<1000))"), 28 | title: UUID().uuidString, 29 | date: Date(), 30 | image: URL(string: "https://picsum.photos/600/600?random&q=\(Int.random(in: 1..<1000))"), 31 | likesCount: UInt.random(in: 1..<100), 32 | viewsCount: UInt.random(in: 1..<1000) 33 | ) 34 | 35 | let listItem = ListItem( 36 | id: item.id, 37 | layoutSpec: FeedItemLayoutSpec(model: item) 38 | ) 39 | 40 | let sep = item.id + "_sep" 41 | 42 | let sepListItem = ListItem( 43 | id: sep, 44 | layoutSpec: FeedItemSeparatorLayoutSpec() 45 | ) 46 | 47 | return [listItem, sepListItem] 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Demos/Code/InRowList/GalleryItemLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class GalleryItemLayoutSpec: ModelLayoutSpec { 6 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 7 | let title = model.name.attributed() 8 | .font(UIFont.boldSystemFont(ofSize: 20)) 9 | .foregroundColor(UIColor.black) 10 | .make() 11 | 12 | return LayoutNodeBuilder().layout { 13 | $0.flexDirection(.column) 14 | }.body { 15 | LayoutNodeBuilder().layout(sizeProvider: title) { 16 | $0.margin(.all(16)) 17 | }.view { (label: UILabel, _) in 18 | label.numberOfLines = 0 19 | label.attributedText = title 20 | } 21 | LayoutNodeBuilder().layout { 22 | $0.height(128) 23 | }.view { (view: GalleryView, _) in 24 | view.images = self.model.images 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Demos/Code/InRowList/GalleryPhotoLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | import Nuke 5 | 6 | final class GalleryPhotoLayoutSpec: ModelLayoutSpec { 7 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 8 | LayoutNodeBuilder().layout { 9 | $0.flexDirection(.row).alignItems(.stretch) 10 | }.body { 11 | LayoutNodeBuilder().layout { 12 | $0.aspectRatio(4.0/3.0) 13 | }.view { (imageView: UIImageView, _) in 14 | imageView.clipsToBounds = true 15 | imageView.contentMode = .scaleAspectFill 16 | 17 | _ = Nuke.loadImage( 18 | with: self.model, 19 | options: ImageLoadingOptions(transition: .fadeIn(duration: 0.33)), 20 | into: imageView 21 | ) 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Demos/Code/InRowList/GalleryView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class GalleryView: UIView, UICollectionViewDelegateFlowLayout { 6 | private lazy var adapter: CollectionViewAdapter = { 7 | let adapter = CollectionViewAdapter(scrollDirection: .horizontal) 8 | adapter.collectionView.delegate = self 9 | adapter.collectionView.backgroundColor = #colorLiteral(red: 0.937254902, green: 0.937254902, blue: 0.9568627451, alpha: 1) 10 | addSubview(adapter.collectionView) 11 | return adapter 12 | }() 13 | 14 | override func layoutSubviews() { 15 | super.layoutSubviews() 16 | 17 | adapter.collectionView.frame = bounds 18 | 19 | adapter.set(boundingDimensions: CGSize(width: .nan, height: bounds.height).layoutDimensions) 20 | } 21 | 22 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 23 | adapter.sizeForItem(at: indexPath.item) ?? .zero 24 | } 25 | 26 | var images: [URL] = [] { 27 | didSet { 28 | DispatchQueue.global().async { [weak self] in 29 | let listItems = (self?.images ?? []).map { url -> ListItem in 30 | ListItem( 31 | id: url, 32 | layoutSpec: GalleryPhotoLayoutSpec(model: url) 33 | ) 34 | } 35 | 36 | DispatchQueue.main.async { 37 | self?.adapter.set(items: listItems) 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Demos/Code/InRowList/MultiGalleriesViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | struct GalleryItem { 6 | let name: String 7 | let images: [URL] 8 | } 9 | 10 | final class MultiGalleriesViewController: ListViewController { 11 | override func viewDidLoad() { 12 | super.viewDidLoad() 13 | 14 | let listItems = DemoContent.NATO.map { name -> ListItem in 15 | let images = (0..<100).map { _ in URL(string: "https://picsum.photos/200/150?random&q=\(Int.random(in: 1..<1000))")! } 16 | 17 | let listItem = ListItem( 18 | id: name, 19 | layoutSpec: GalleryItemLayoutSpec(model: GalleryItem(name: name, images: images)) 20 | ) 21 | 22 | return listItem 23 | } 24 | 25 | adapter.set(items: listItems) 26 | } 27 | 28 | override func viewWillLayoutSubviews() { 29 | super.viewWillLayoutSubviews() 30 | 31 | adapter.set(boundingDimensions: CGSize(width: view.bounds.width, height: .nan).layoutDimensions) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Demos/Code/Mail/MailRowLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | import yoga 5 | 6 | final class MailRowLayoutSpec: ModelLayoutSpec { 7 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 8 | let attributedTitle = model.title.attributed() 9 | .font(.boldSystemFont(ofSize: 16)) 10 | .foregroundColor(#colorLiteral(red: 0, green: 0, blue: 0, alpha: 1)) 11 | .make() 12 | 13 | let attributedText = model.text.attributed() 14 | .font(.systemFont(ofSize: 12)) 15 | .foregroundColor(#colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1)) 16 | .make() 17 | 18 | return LayoutNodeBuilder().layout { 19 | $0.flexDirection(.column) 20 | }.body { 21 | LayoutNodeBuilder().layout { 22 | $0.flex(1).margin(.top(12), .left(16), .bottom(12), .right(8)) 23 | }.body { 24 | LayoutNodeBuilder().layout(sizeProvider: attributedTitle) { 25 | $0.max(.height(56)).margin(.bottom(4)) 26 | }.view { (label: UILabel, _) in 27 | label.numberOfLines = 0 28 | label.attributedText = attributedTitle 29 | } 30 | LayoutNodeBuilder().layout(sizeProvider: attributedText) { 31 | $0.max(.height(40)) 32 | }.view { (label: UILabel, _) in 33 | label.numberOfLines = 0 34 | label.attributedText = attributedText 35 | } 36 | } 37 | LayoutNodeBuilder().layout { 38 | $0.margin(.left(16)).height(.point(1.0/UIScreen.main.scale)) 39 | }.view { (view: UIView, _) in 40 | view.backgroundColor = #colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Demos/Code/Mail/MailRowSwipeActionLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class MailRowSwipeActionLayoutSpec: ModelLayoutSpec { 6 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 7 | let text = model.text.attributed() 8 | .font(UIFont.boldSystemFont(ofSize: 12)) 9 | .foregroundColor(UIColor.white) 10 | .make() 11 | 12 | return LayoutNodeBuilder().layout { 13 | $0.flexDirection(.column).alignItems(.center).justifyContent(.center) 14 | }.body { 15 | LayoutNodeBuilder().layout { 16 | $0.height(24).width(24).margin(.bottom(4)) 17 | }.view { (imageView: UIImageView, _) in 18 | imageView.contentMode = .scaleAspectFit 19 | imageView.tintColor = UIColor.white 20 | imageView.image = self.model.image.withRenderingMode(.alwaysTemplate) 21 | } 22 | LayoutNode(sizeProvider: text) { (label: UILabel, _) in 23 | label.numberOfLines = 0 24 | label.attributedText = text 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Demos/Code/Mail/MailViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | struct MailRow: Equatable { 6 | let id = UUID().uuidString 7 | let title: String 8 | let text: String 9 | 10 | static func ==(lhs: MailRow, rhs: MailRow) -> Bool { 11 | return lhs.id == rhs.id 12 | } 13 | } 14 | 15 | struct MailRowSwipeItem { 16 | let image: UIImage 17 | let text: String 18 | } 19 | 20 | final class MailViewController: ListViewController { 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | 24 | DispatchQueue.global().async { 25 | let sentences = DemoContent.loremIpsum 26 | 27 | let rows = (0..<20).map({ _ -> MailRow in 28 | let text = sentences.randomElement()! 29 | 30 | return MailRow(title: UUID().uuidString, text: text) 31 | }) 32 | 33 | DispatchQueue.main.async { 34 | self.rows = rows 35 | } 36 | } 37 | } 38 | 39 | override func viewWillLayoutSubviews() { 40 | super.viewWillLayoutSubviews() 41 | 42 | adapter.set(boundingDimensions: CGSize(width: view.bounds.width, height: .nan).layoutDimensions) 43 | } 44 | 45 | private var rows: [MailRow] = [] { 46 | didSet { 47 | let items = rows.map { row -> ListItem in 48 | let item = ListItem( 49 | id: row.id, 50 | layoutSpec: MailRowLayoutSpec(model: row) 51 | ) 52 | 53 | let deleteAction = SwipeAction( 54 | layoutSpec: MailRowSwipeActionLayoutSpec(model: MailRowSwipeItem(image: UIImage(named: "trash")!, text: "Delete")), 55 | setup: ({ [weak self] view, close in 56 | view.backgroundColor = #colorLiteral(red: 0.7450980544, green: 0.1884698107, blue: 0.1212462212, alpha: 1) 57 | view.all_addGestureRecognizer { (_: UITapGestureRecognizer) in 58 | guard let strongSelf = self else { return } 59 | 60 | close(true) 61 | 62 | _ = strongSelf.rows.firstIndex(of: row).flatMap { strongSelf.rows.remove(at: $0) } 63 | } 64 | }) 65 | ) 66 | 67 | let customAction = SwipeAction( 68 | layoutSpec: MailRowSwipeActionLayoutSpec(model: MailRowSwipeItem(image: UIImage(named: "setting")!, text: "Other")), 69 | setup: ({ [weak self] view, close in 70 | view.backgroundColor = #colorLiteral(red: 0.2745098174, green: 0.4862745106, blue: 0.1411764771, alpha: 1) 71 | view.all_addGestureRecognizer { (_: UITapGestureRecognizer) in 72 | guard let strongSelf = self else { return } 73 | 74 | close(true) 75 | 76 | let alert = UIAlertController(title: "WOW", message: "This is alert", preferredStyle: .alert) 77 | 78 | alert.addAction(UIAlertAction(title: "Close me", style: .cancel, handler: nil)) 79 | 80 | strongSelf.present(alert, animated: true, completion: nil) 81 | } 82 | }) 83 | ) 84 | 85 | if let actions = SwipeActions([customAction, deleteAction]) { 86 | item.makeView = { layout, index -> UIView in 87 | actions.makeView(contentLayout: layout) 88 | } 89 | } 90 | 91 | return item 92 | } 93 | 94 | adapter.set(items: items) 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Demos/Code/Movement/MovementViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class MovementViewController: ListViewController { 6 | private struct Consts { 7 | static let spacing: CGFloat = 4 8 | } 9 | 10 | init() { 11 | super.init(adapter: CollectionViewAdapter( 12 | scrollDirection: .vertical, 13 | sectionInset: UIEdgeInsets(top: Consts.spacing, left: Consts.spacing, bottom: Consts.spacing, right: Consts.spacing), 14 | minimumLineSpacing: Consts.spacing, 15 | minimumInteritemSpacing: Consts.spacing 16 | )) 17 | } 18 | 19 | required init?(coder: NSCoder) { 20 | fatalError() 21 | } 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | 26 | do { 27 | adapter.settings.allowInteractiveMovement = true 28 | 29 | adapter.collectionView.all_addGestureRecognizer { [weak self] (g: UILongPressGestureRecognizer) in 30 | self?.adapter.handleMoveGesture(g) 31 | } 32 | } 33 | 34 | do { 35 | navigationItem.rightBarButtonItem = UIBarButtonItem( 36 | barButtonSystemItem: .refresh, 37 | target: self, 38 | action: #selector(updateItems) 39 | ) 40 | } 41 | 42 | updateItems() 43 | } 44 | 45 | @objc 46 | private func updateItems() { 47 | let items = (0..<9).map { number -> ListItem in 48 | let listItem = ListItem( 49 | id: number, 50 | layoutSpec: NumberLayoutSpec(model: number) 51 | ) 52 | 53 | listItem.canMove = true 54 | 55 | listItem.didMove = { from, to in 56 | print("Did move item \"\(number)\" from \(from) to \(to)") 57 | } 58 | 59 | return listItem 60 | } 61 | 62 | adapter.set(items: items) 63 | } 64 | 65 | override func viewWillLayoutSubviews() { 66 | super.viewWillLayoutSubviews() 67 | 68 | let numberOfColumns = 3 69 | 70 | let size = (view.bounds.width - CGFloat(numberOfColumns + 1) * Consts.spacing) / CGFloat(numberOfColumns) - 1 71 | 72 | adapter.set(boundingDimensions: CGSize(width: size, height: size).layoutDimensions) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Demos/Code/Root/RootViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | private final class SwipeTextLayoutSpec: ModelLayoutSpec { 6 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 7 | let attrText = model.attributed() 8 | .font(UIFont.boldSystemFont(ofSize: 40)) 9 | .alignment(.center) 10 | .make() 11 | 12 | return LayoutNode(sizeProvider: attrText) { (label: UILabel, _) in 13 | label.attributedText = attrText 14 | } 15 | } 16 | } 17 | 18 | final class RootViewController: ListViewController { 19 | override func viewDidLoad() { 20 | super.viewDidLoad() 21 | 22 | title = "Select demo" 23 | 24 | navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) 25 | 26 | do { 27 | typealias MenuRow = (name: String, onSelect: () -> Void) 28 | 29 | let menuRows: [MenuRow] = [ 30 | ("Feed", { [weak self] in 31 | let vc = FeedViewController() 32 | 33 | self?.navigationController?.pushViewController(vc, animated: true) 34 | }), 35 | ("Chat", { [weak self] in 36 | let vc = ChatViewController() 37 | 38 | self?.navigationController?.pushViewController(vc, animated: true) 39 | }), 40 | ("Mail (swipe demo)", { [weak self] in 41 | let vc = MailViewController() 42 | 43 | self?.navigationController?.pushViewController(vc, animated: true) 44 | }), 45 | ("Combine layouts (plain UIScrollView)", { [weak self] in 46 | let vc = CombinedLayoutViewController() 47 | 48 | self?.navigationController?.pushViewController(vc, animated: true) 49 | }), 50 | ("Layout transition (different layouts for portrait and landscape orientations)", { [weak self] in 51 | let vc = LayoutTransitionViewController() 52 | 53 | self?.navigationController?.pushViewController(vc, animated: true) 54 | }), 55 | ("Animations", { [weak self] in 56 | let vc = LayoutAnimationViewController() 57 | 58 | self?.navigationController?.pushViewController(vc, animated: true) 59 | }), 60 | ("Diff", { [weak self] in 61 | let vc = AutoDiffViewController() 62 | 63 | self?.navigationController?.pushViewController(vc, animated: true) 64 | }), 65 | ("Interactive movement", { [weak self] in 66 | let vc = MovementViewController() 67 | 68 | self?.navigationController?.pushViewController(vc, animated: true) 69 | }), 70 | ("Size constraints (different cell sizes)", { [weak self] in 71 | let vc = SizeConstraintsDemoViewController() 72 | 73 | self?.navigationController?.pushViewController(vc, animated: true) 74 | }), 75 | ("Waterfall (custom collection layout)", { [weak self] in 76 | let vc = WaterfallViewController() 77 | 78 | self?.navigationController?.pushViewController(vc, animated: true) 79 | }), 80 | ("Horizontal list in row", { [weak self] in 81 | let vc = MultiGalleriesViewController() 82 | 83 | self?.navigationController?.pushViewController(vc, animated: true) 84 | }) 85 | ] 86 | 87 | let items = menuRows.enumerated().map { (index, row) -> ListItem in 88 | let rowItem = ListItem( 89 | id: row.name, 90 | layoutSpec: SelectableRowLayoutSpec(model: row.name) 91 | ) 92 | 93 | rowItem.context = DemoContext(onSelect: row.onSelect) 94 | 95 | return rowItem 96 | } 97 | 98 | adapter.set(items: items) 99 | } 100 | } 101 | 102 | override func viewWillAppear(_ animated: Bool) { 103 | super.viewWillAppear(animated) 104 | 105 | navigationController?.setToolbarHidden(true, animated: animated) 106 | } 107 | 108 | override func viewWillLayoutSubviews() { 109 | super.viewWillLayoutSubviews() 110 | 111 | adapter.set(boundingDimensions: CGSize(width: view.bounds.width, height: .nan).layoutDimensions) 112 | } 113 | 114 | override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 115 | collectionView.deselectItem(at: indexPath, animated: true) 116 | 117 | adapter.contextForItem(at: indexPath.item)?.onSelect?() 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Demos/Code/Root/SelectableRowLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | import yoga 5 | 6 | final class SelectableRowLayoutSpec: ModelLayoutSpec { 7 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 8 | let attributedText = model.attributed() 9 | .font(.systemFont(ofSize: 14)) 10 | .foregroundColor(#colorLiteral(red: 0, green: 0, blue: 0, alpha: 1)) 11 | .make() 12 | .drawing() 13 | 14 | return LayoutNodeBuilder().layout { 15 | $0.flexDirection(.column) 16 | }.body { 17 | LayoutNodeBuilder().layout { 18 | $0.flexDirection(.row) 19 | .alignItems(.center) 20 | .justifyContent(.spaceBetween) 21 | .padding(.vertical(12), .left(16), .right(8)) 22 | }.body { 23 | LayoutNodeBuilder().layout(sizeProvider: attributedText) { 24 | $0.flex(1) 25 | }.view { (label: AsyncLabel, _) in 26 | label.stringDrawing = attributedText 27 | } 28 | LayoutNodeBuilder().layout { 29 | $0.width(24).height(24).margin(.left(6)) 30 | }.view { (imageView: UIImageView, _) in 31 | imageView.contentMode = .scaleAspectFit 32 | imageView.tintColor = #colorLiteral(red: 0.2549019754, green: 0.2745098174, blue: 0.3019607961, alpha: 1) 33 | imageView.image = UIImage(named: "arrow_right")?.withRenderingMode(.alwaysTemplate) 34 | } 35 | } 36 | LayoutNodeBuilder().layout { 37 | $0.height(.point(1.0/UIScreen.main.scale)).margin(.left(16)) 38 | }.view { (view: UIView, _) in 39 | view.backgroundColor = #colorLiteral(red: 0.6000000238, green: 0.6000000238, blue: 0.6000000238, alpha: 1) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Demos/Code/SizeConstraints/EmojiLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class EmojiLayoutSpec: ModelLayoutSpec { 6 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 7 | let emojiText = model.attributed().font(UIFont.boldSystemFont(ofSize: 32)).make() 8 | 9 | return LayoutNodeBuilder().layout().body { 10 | LayoutNodeBuilder().layout { 11 | $0.alignItems(.center).justifyContent(.center).height(64).margin(.all(2)) 12 | }.view { (view: UIView, _) in 13 | view.backgroundColor = #colorLiteral(red: 0.2392156869, green: 0.6745098233, blue: 0.9686274529, alpha: 1) 14 | }.body { 15 | LayoutNode(sizeProvider: emojiText) { (label: UILabel, _) in 16 | label.attributedText = emojiText 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demos/Code/SizeConstraints/SizeConstraintsDemoViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | final class SizeConstraintsDemoViewController: ListViewController { 6 | init() { 7 | super.init(adapter: CollectionViewAdapter( 8 | scrollDirection: .vertical, 9 | sectionInset: UIEdgeInsets(top: 2, left: 2, bottom: 2, right: 2), 10 | minimumLineSpacing: 0, 11 | minimumInteritemSpacing: 0 12 | )) 13 | } 14 | 15 | required init?(coder: NSCoder) { 16 | fatalError("init(coder:) has not been implemented") 17 | } 18 | 19 | override func viewDidLoad() { 20 | super.viewDidLoad() 21 | 22 | adapter.set(items: generateItems()) 23 | } 24 | 25 | override func viewWillLayoutSubviews() { 26 | super.viewWillLayoutSubviews() 27 | 28 | adapter.set( 29 | boundingDimensions: CGSize( 30 | width: view.bounds.width - 4, 31 | height: .nan 32 | ).layoutDimensions 33 | ) 34 | } 35 | 36 | private func generateItems() -> [ListItem] { 37 | var items = [ListItem]() 38 | 39 | (0..<100).forEach { i in 40 | let n = Int(arc4random_uniform(5)) 41 | 42 | (0..( 48 | id: id, 49 | layoutSpec: EmojiLayoutSpec(model: emoji) 50 | ) 51 | 52 | listItem.boundingDimensionsModifier = { (w, h) in 53 | ((w / CGFloat(n)).rounded(.down), .nan) 54 | } 55 | 56 | items.append(listItem) 57 | } 58 | } 59 | 60 | return items 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Demos/Code/Transition/LayoutTransitionViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | struct UserProfile { 6 | let avatar: URL? 7 | let name: String 8 | let description: String 9 | } 10 | 11 | final class LayoutTransitionViewController: UIViewController { 12 | private let userProfile = UserProfile( 13 | avatar: URL(string: "https://picsum.photos/100/100?random&q=\(Int.random(in: 1..<1000))"), 14 | name: "John Smith", 15 | description: DemoContent.loremIpsum.joined(separator: ". ") 16 | ) 17 | 18 | private lazy var scrollView = UIScrollView() 19 | private lazy var contentView = UIView() 20 | 21 | private lazy var portraitLayoutSpec = PortraitProfileLayoutSpec(model: userProfile) 22 | private lazy var landscapeLayoutSpec = LandscapeProfileLayoutSpec(model: userProfile) 23 | 24 | override func viewDidLoad() { 25 | super.viewDidLoad() 26 | 27 | do { 28 | view.backgroundColor = UIColor.white 29 | 30 | view.addSubview(scrollView) 31 | 32 | scrollView.addSubview(contentView) 33 | } 34 | } 35 | 36 | override func viewWillLayoutSubviews() { 37 | super.viewWillLayoutSubviews() 38 | 39 | scrollView.frame = view.bounds 40 | 41 | let size = view.bounds.size 42 | 43 | let layoutSpec = size.width > size.height ? landscapeLayoutSpec : portraitLayoutSpec 44 | 45 | let layout = layoutSpec.makeLayoutWith(boundingDimensions: CGSize(width: size.width, height: .nan).layoutDimensions) 46 | 47 | scrollView.contentSize = layout.size 48 | 49 | layout.setup(in: contentView) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Demos/Code/Transition/ProfileLayoutSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | import Nuke 5 | 6 | final class PortraitProfileLayoutSpec: ModelLayoutSpec { 7 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 8 | let attributedName = model.name.attributed() 9 | .font(.boldSystemFont(ofSize: 24)) 10 | .foregroundColor(UIColor.black) 11 | .alignment(.center) 12 | .make() 13 | 14 | let attributedDescription = model.description.attributed() 15 | .font(.systemFont(ofSize: 14)) 16 | .foregroundColor(UIColor.black) 17 | .alignment(.justified) 18 | .make() 19 | 20 | return LayoutNodeBuilder().layout { 21 | $0.flexDirection(.column).padding(.all(24)).alignItems(.center) 22 | }.body { 23 | LayoutNodeBuilder().layout { 24 | $0.height(100).width(100).margin(.bottom(24)) 25 | }.view { (imageView: UIImageView, isNew) in 26 | if isNew { 27 | imageView.backgroundColor = #colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1) 28 | imageView.layer.cornerRadius = 50 29 | imageView.layer.masksToBounds = true 30 | imageView.contentMode = .scaleAspectFill 31 | 32 | self.model.avatar.flatMap { 33 | _ = Nuke.loadImage( 34 | with: $0, 35 | options: ImageLoadingOptions(transition: .fadeIn(duration: 0.33)), 36 | into: imageView 37 | ) 38 | } 39 | } 40 | } 41 | 42 | LayoutNodeBuilder().layout(sizeProvider: attributedName) { 43 | $0.margin(.bottom(24)) 44 | }.view { (label: UILabel, isNew) in 45 | if isNew { 46 | label.numberOfLines = 0 47 | label.attributedText = attributedName 48 | } 49 | } 50 | 51 | LayoutNodeBuilder().layout(sizeProvider: attributedDescription).view { (label: UILabel, isNew) in 52 | if isNew { 53 | label.numberOfLines = 0 54 | label.attributedText = attributedDescription 55 | } 56 | } 57 | } 58 | } 59 | } 60 | 61 | final class LandscapeProfileLayoutSpec: ModelLayoutSpec { 62 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 63 | let attributedName = model.name.attributed() 64 | .font(.boldSystemFont(ofSize: 24)) 65 | .foregroundColor(UIColor.black) 66 | .alignment(.center) 67 | .make() 68 | 69 | let attributedDescription = model.description.attributed() 70 | .font(.systemFont(ofSize: 14)) 71 | .foregroundColor(UIColor.black) 72 | .alignment(.justified) 73 | .make() 74 | 75 | return LayoutNodeBuilder().layout { 76 | $0.flexDirection(.row).padding(.all(24)) 77 | }.body { 78 | LayoutNodeBuilder().layout { 79 | $0.flexDirection(.column).alignItems(.center).margin(.right(24)) 80 | }.body { 81 | LayoutNodeBuilder().layout { 82 | $0.height(100).width(100).margin(.bottom(24)) 83 | }.view { (imageView: UIImageView, isNew) in 84 | if isNew { 85 | imageView.backgroundColor = #colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1) 86 | imageView.layer.cornerRadius = 50 87 | imageView.layer.masksToBounds = true 88 | imageView.contentMode = .scaleAspectFill 89 | 90 | self.model.avatar.flatMap { 91 | _ = Nuke.loadImage( 92 | with: $0, 93 | options: ImageLoadingOptions(transition: .fadeIn(duration: 0.33)), 94 | into: imageView 95 | ) 96 | } 97 | } 98 | } 99 | LayoutNode(sizeProvider: attributedName) { (label: UILabel, isNew) in 100 | if isNew { 101 | label.numberOfLines = 0 102 | label.attributedText = attributedName 103 | } 104 | } 105 | } 106 | LayoutNodeBuilder().layout(sizeProvider: attributedDescription) { 107 | $0.flex(1) 108 | }.view { (label: UILabel, isNew) in 109 | if isNew { 110 | label.numberOfLines = 0 111 | label.attributedText = attributedDescription 112 | } 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Demos/Code/Utils/DemoContent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct DemoContent { 4 | static let loremIpsum = ["Lorem ipsum dolor sit amet, consectetur adipiscing elit, 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"] 5 | 6 | static let emodjiString = "😘🔒🔗👅👐❤️🙂👍🏻🖌🗣👅👐🖖🏻👐👅😘🖖🏻🤘🏻👅🗣🖌🔒📎👀👍🏻😁😆🔓😆🗣🤘🏻📕🔒" 7 | 8 | static let NATO = ["Alfa", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliett", "Kilo", "Lima", "Mike", "November", "Oscar", "Papa", "Quebec", "Romeo", "Sierra", "Tango", "Uniform", "Victor", "Whiskey", "X-ray", "Yankee", "Zulu"] 9 | } 10 | -------------------------------------------------------------------------------- /Demos/Code/Utils/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // ALLKit 4 | // 5 | // Created by Georgy Kasapidi on 2/15/20. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import ALLKit 11 | 12 | struct DemoContext { 13 | var onSelect: (() -> Void)? 14 | var willDisplay: ((UIView) -> Void)? 15 | var didEndDisplaying: ((UIView) -> Void)? 16 | } 17 | 18 | class ListViewController: UIViewController, UICollectionViewDelegateFlowLayout { 19 | let adapter: CollectionViewAdapter 20 | 21 | init(adapter: CollectionViewAdapter = CollectionViewAdapter()) { 22 | self.adapter = adapter 23 | 24 | super.init(nibName: nil, bundle: nil) 25 | 26 | adapter.collectionView.delegate = self 27 | } 28 | 29 | required init?(coder: NSCoder) { 30 | fatalError() 31 | } 32 | 33 | override func viewDidLoad() { 34 | super.viewDidLoad() 35 | 36 | view.backgroundColor = UIColor.white 37 | view.addSubview(adapter.collectionView) 38 | adapter.collectionView.backgroundColor = UIColor.white 39 | adapter.collectionView.alwaysBounceVertical = true 40 | } 41 | 42 | override func viewWillLayoutSubviews() { 43 | super.viewWillLayoutSubviews() 44 | 45 | adapter.collectionView.frame = view.bounds 46 | } 47 | 48 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 49 | adapter.sizeForItem(at: indexPath.item) ?? .zero 50 | } 51 | 52 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {} 53 | 54 | func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {} 55 | 56 | func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {} 57 | } 58 | 59 | extension CollectionViewAdapter { 60 | public convenience init(scrollDirection: UICollectionView.ScrollDirection = .vertical, 61 | sectionInset: UIEdgeInsets = .zero, 62 | minimumLineSpacing: CGFloat = 0, 63 | minimumInteritemSpacing: CGFloat = 0) { 64 | let flowLayout = UICollectionViewFlowLayout() 65 | 66 | do { 67 | flowLayout.scrollDirection = scrollDirection 68 | flowLayout.sectionInset = sectionInset 69 | flowLayout.minimumLineSpacing = minimumLineSpacing 70 | flowLayout.minimumInteritemSpacing = minimumInteritemSpacing 71 | flowLayout.headerReferenceSize = .zero 72 | flowLayout.footerReferenceSize = .zero 73 | } 74 | 75 | self.init(layout: flowLayout) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Demos/Code/Waterfall/WaterfallLayout.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | protocol WaterfallLayoutDelegate: class { 5 | func heightForItemAt(indexPath: IndexPath) -> CGFloat 6 | } 7 | 8 | final class WaterfallLayout: UICollectionViewLayout { 9 | let numberOfColumns: Int 10 | let spacing: CGFloat 11 | 12 | init(numberOfColumns: Int, spacing: CGFloat = 0) { 13 | precondition(numberOfColumns > 1 && spacing >= 0) 14 | 15 | self.numberOfColumns = numberOfColumns 16 | self.spacing = spacing 17 | 18 | super.init() 19 | } 20 | 21 | required init?(coder aDecoder: NSCoder) { 22 | fatalError() 23 | } 24 | 25 | weak var delegate: WaterfallLayoutDelegate! 26 | 27 | // MARK: - 28 | 29 | private var contentSize: CGSize = .zero 30 | private var layoutAttributesCache: [UICollectionViewLayoutAttributes] = [] 31 | 32 | // MARK: - 33 | 34 | override func prepare() { 35 | guard let collectionView = collectionView, layoutAttributesCache.isEmpty else { 36 | return 37 | } 38 | 39 | let collectionViewWidth = collectionView.bounds.width 40 | let columnWidth = columnWidthFor(viewWidth: collectionViewWidth) 41 | 42 | let xOffset: [CGFloat] = (0.. [UICollectionViewLayoutAttributes]? { 85 | return layoutAttributesCache.filter { 86 | $0.frame.intersects(rect) 87 | } 88 | } 89 | 90 | override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { 91 | return layoutAttributesCache[indexPath.item] 92 | } 93 | 94 | // MARK: - 95 | 96 | func columnWidthFor(viewWidth: CGFloat) -> CGFloat { 97 | return (viewWidth - CGFloat(numberOfColumns + 1) * spacing) / CGFloat(numberOfColumns) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Demos/Code/Waterfall/WaterfallViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import ALLKit 4 | 5 | private final class WaterfallColorLayoutSpec: ModelLayoutSpec { 6 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 7 | let textString = model.attributed() 8 | .font(UIFont.systemFont(ofSize: 12)) 9 | .foregroundColor(UIColor.gray) 10 | .make() 11 | 12 | return LayoutNodeBuilder().layout { 13 | $0.padding(.all(8)) 14 | }.view { (view: UIView, _) in 15 | view.layer.borderWidth = 1 16 | view.layer.borderColor = UIColor.black.cgColor 17 | view.layer.cornerRadius = 4 18 | view.layer.masksToBounds = true 19 | }.body { 20 | LayoutNode(sizeProvider: textString) { (label: UILabel, _) in 21 | label.numberOfLines = 0 22 | label.attributedText = textString 23 | } 24 | } 25 | } 26 | } 27 | 28 | final class WaterfallViewController: ListViewController, WaterfallLayoutDelegate { 29 | private let waterfallLayout = WaterfallLayout(numberOfColumns: 2, spacing: 4) 30 | 31 | init() { 32 | super.init(adapter: CollectionViewAdapter(layout: waterfallLayout)) 33 | 34 | waterfallLayout.delegate = self 35 | } 36 | 37 | required init?(coder aDecoder: NSCoder) { 38 | fatalError() 39 | } 40 | 41 | override func viewDidLoad() { 42 | super.viewDidLoad() 43 | 44 | DispatchQueue.global().async { [weak self] in 45 | let content = DemoContent.loremIpsum.joined(separator: " ") 46 | 47 | let items: [ListItem] = (0..( 51 | id: String(index), 52 | layoutSpec: WaterfallColorLayoutSpec(model: String(content[randomIndex...])) 53 | ) 54 | 55 | return item 56 | } 57 | 58 | DispatchQueue.main.async { 59 | self?.adapter.set(items: items) 60 | } 61 | } 62 | } 63 | 64 | override func viewWillLayoutSubviews() { 65 | super.viewWillLayoutSubviews() 66 | 67 | adapter.set( 68 | boundingDimensions: CGSize( 69 | width: waterfallLayout.columnWidthFor(viewWidth: view.bounds.width), 70 | height: .nan 71 | ).layoutDimensions 72 | ) 73 | } 74 | 75 | func heightForItemAt(indexPath: IndexPath) -> CGFloat { 76 | return adapter.sizeForItem(at: indexPath.item)?.height ?? 0 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Docs/allkit_screens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geor-kasapidi/ALLKit/a56dee5d4fae6acadd644afbb70f23b84ea6e073/Docs/allkit_screens.png -------------------------------------------------------------------------------- /Docs/animations.md: -------------------------------------------------------------------------------- 1 | # Animations 2 | 3 | When you apply a layout to view with existing subviews, you change their frames and other properties that you specified in the configuration block. 4 | 5 | To animate the changes, simply do this in the animation block: 6 | 7 | ```swift 8 | let layout: Layout = ... 9 | let view: UIView = ... 10 | 11 | UIView.animate(withDuration: 0.5) { 12 | layout.setup(in: view) 13 | } 14 | ``` 15 | -------------------------------------------------------------------------------- /Docs/async_text.md: -------------------------------------------------------------------------------- 1 | # Async text rendering 2 | 3 | Text SDK provided by iOS is highly optimized, but sometimes text rendering can take a lot of time. 4 | If you have an attributed string with a large number of emoji or image attachments (ex. chat app), rendering can takes tens of milliseconds. 5 | This is unacceptable because text views draw text on main thread. 6 | 7 | For drawing text in background, ALLKit provides special API (available by Extended subspec): 8 | 9 | 1. [AttributedStringDrawing](../Sources/Support/AttributedStringDrawing.swift) - object that knows how to calculate text size and create a bitmap from text. 10 | 11 | 2. [AsyncLabel](../Sources/Support/AsyncLabel.swift) - subclass of UIView that can draw text asynchronously. 12 | 13 | ### Example of usage 14 | 15 | ```swift 16 | final class SomeLayoutSpec: ModelLayoutSpec { 17 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 18 | // make attributed string 19 | let string = model.attributed() 20 | .font(UIFont.systemFont(ofSize: 15)) 21 | .foregroundColor(UIColor.black) 22 | .make() 23 | 24 | // make string drawing object with options and context 25 | let stringDrawing = string.drawing(options: .usesLineFragmentOrigin, context: nil) 26 | 27 | return LayoutNodeBuilder().layout().body { 28 | // use drawing object as size provider 29 | LayoutNodeBuilder().layout(sizeProvider: stringDrawing) { 30 | $0.margin(.all(16)) 31 | }.view { (label: AsyncLabel, _) in 32 | // pass drawing instance to async label 33 | label.stringDrawing = stringDrawing 34 | } 35 | } 36 | } 37 | } 38 | ``` 39 | 40 | *Note*. AsyncLabel requires the right frame before drawing the text. 41 | 42 | **RULE**. You should always draw text with the same API that calculates its size. NSAttributedString -> NSAttributedString, NSLayoutManager -> NSLayoutManager, etc. 43 | -------------------------------------------------------------------------------- /Docs/auto_diff.md: -------------------------------------------------------------------------------- 1 | # AutoDiff 2 | 3 | Diffing is a very popular technique in modern mobile apps development. 4 | It's easy to convert changes between two lists (inserts, deletes, moves, updates) into a UICollectionView batch update. 5 | 6 | To find changes between arrays, elements must be **Hashable** or **[Diffable](../Sources/Diff/Diff.swift)**: 7 | 8 | ```swift 9 | let old = Array("abc") 10 | let new = Array("abcd") 11 | 12 | let changes = Diff.between(old, and: new) 13 | ``` 14 | 15 | [Provided implementation](../Sources/Diff/Diff.swift) is based on the Paul Heckel's algorithm. 16 | 17 | [CollectionViewAdapter](list_view.md) uses diffing to animate UICollectionView updates. 18 | -------------------------------------------------------------------------------- /Docs/basic_concepts.md: -------------------------------------------------------------------------------- 1 | # Basic concepts 2 | 3 | ALLKit has 3 core entities: layout node, layout spec and layout. 4 | 5 | ## Layout node 6 | 7 | Layout node is an atomic unit of UI - objective bridge between flexbox and UIKit. 8 | 9 | There are 4 types of layout nodes: 10 | 11 | | | standard sized | custom sized | 12 | |-------------|:--------------:|:------------:| 13 | | has view | A | C | 14 | | has no view | B | D | 15 | 16 | * **A** 17 | 18 | ```swift 19 | LayoutNode(children: [/*...*/], { 20 | /* flexbox properties */ 21 | }) { (view: ViewType, isNew) in 22 | /* view properties */ 23 | } 24 | ``` 25 | 26 | * **B** 27 | 28 | ```swift 29 | LayoutNode(children: [/*...*/], { 30 | /* flexbox properties */ 31 | }) 32 | ``` 33 | 34 | * **C** 35 | 36 | ```swift 37 | let sizeProvider: SizeProvider 38 | 39 | LayoutNode(sizeProvider: sizeProvider, { 40 | /* flexbox properties */ 41 | }) { (view: ViewType, isNew) in 42 | /* view properties */ 43 | } 44 | ``` 45 | 46 | * **D** 47 | 48 | ```swift 49 | let sizeProvider: SizeProvider 50 | 51 | LayoutNode(sizeProvider: sizeProvider, { 52 | /* flexbox properties */ 53 | }) 54 | ``` 55 | 56 | In common case (**A**, **B**) to calculate layout you need to establish relationship between nodes (make node tree) and set up their flexbox properties. But sometimes (**C**, **D**) layout algorithm needs extra information about node size. There is a special protocol for this purpose - [SizeProvider](../Sources/Layout/SizeProvider.swift). Object that implements this protocol can calculate the size based on the width and height constraints. Typical example is text - in iOS you can calculate `NSAttributedString` size using `boundingRect` method. Nodes with size providers are always leafs. 57 | 58 | Nodes with views (**A**, **C**) has trailing closure with generic view type (you can use `UIView` and its subclasses) and special optional to use parameter `isNew`, which is true if view is only created. Views passed to the closure always have correct frame. Note, that no default views are created for nodes without user-defined views (**B**, **D**). 59 | 60 | ## Layout spec 61 | 62 | If layout node is an atom, then layout spec is a molecule - group of atoms. Layout spec is a declarative UI component. Each component is a subclass of `LayoutSpec` or `ModelLayoutSpec`: 63 | 64 | ```swift 65 | final class YourLayoutSpec: LayoutSpec { 66 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 67 | ... 68 | } 69 | } 70 | ``` 71 | 72 | ```swift 73 | final class YourLayoutSpec: ModelLayoutSpec { 74 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 75 | self.model... 76 | } 77 | } 78 | ``` 79 | 80 | Layout specs have two important features: 81 | 82 | 1. **Encapsulation** - UI building is separate from other logic. 83 | 2. **Composition** - components can be easily combined: 84 | 85 | ```swift 86 | struct Model1 { 87 | ... 88 | } 89 | 90 | struct Model2 { 91 | let submodel: Model1 92 | ... 93 | } 94 | 95 | final class LayoutSpec1: ModelLayoutSpec { 96 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 97 | ... 98 | } 99 | } 100 | 101 | final class LayoutSpec2: ModelLayoutSpec { 102 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 103 | let node1 = LayoutSpec1(model: model.submodel).makeNodeWith(boundingDimensions: boundingDimensions) 104 | 105 | let node2 = LayoutNode(children: [node1]) 106 | 107 | ... 108 | } 109 | } 110 | ``` 111 | 112 | ## Layout 113 | 114 | Layout is a view stencil. 115 | 116 | 1. Spec makes layout: 117 | 118 | ```swift 119 | let layout = layoutSpec.makeLayoutWith( 120 | boundingDimensions: CGSize(...).layoutDimensions 121 | ) 122 | ``` 123 | 124 | 2. Layout makes view: 125 | 126 | ```swift 127 | let view = layout.makeView() // convenience method 128 | ``` 129 | 130 | or 131 | 132 | ```swift 133 | view.frame = CGRect(origin: .zero, size: layout.size) 134 | 135 | layout.makeContentIn(view: view) 136 | ``` 137 | 138 | The default implementation of the `Layout` protocol creates subviews in the provided view (if no subviews), otherwise reuses existing subviews. 139 | 140 | Methods `makeContentIn` and `makeView` must be used from main thread. 141 | 142 | ## Example 143 | 144 | [Demo component](hello_world.md) 145 | -------------------------------------------------------------------------------- /Docs/hello_world.md: -------------------------------------------------------------------------------- 1 | # Spec example 2 | 3 | ![Hello, world](hello_world.png) 4 | 5 | ### Model 6 | 7 | ```swift 8 | struct DemoLayoutModel { 9 | let image: UIImage 10 | let title: String 11 | } 12 | ``` 13 | 14 | ### Spec 15 | 16 | ```swift 17 | final class DemoLayoutSpec: ModelLayoutSpec { 18 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 19 | let titleString = model.title.attributed() 20 | .font(UIFont.systemFont(ofSize: 16)) 21 | .foregroundColor(UIColor.black) 22 | .make() 23 | 24 | // root container node 25 | return LayoutNodeBuilder().layout { 26 | $0.flexDirection(.row).alignItems(.center).padding(.all(16)) 27 | }.body { 28 | // image node 29 | LayoutNodeBuilder().layout { 30 | $0.width(48).height(48) 31 | }.view { (imageView: UIImageView, isNew) in 32 | if isNew /* if view is only created */ { 33 | imageView.contentMode = .scaleAspectFill 34 | imageView.layer.cornerRadius = 24 35 | imageView.layer.masksToBounds = true 36 | imageView.layer.borderWidth = 1 37 | imageView.layer.borderColor = UIColor.lightGray.cgColor 38 | imageView.backgroundColor = UIColor.lightGray 39 | } 40 | 41 | imageView.image = self.model.image 42 | } 43 | // title node 44 | LayoutNodeBuilder().layout(sizeProvider: titleString) { 45 | $0.margin(.left(16)) 46 | }.view { (label: UILabel, _) in 47 | label.numberOfLines = 0 48 | label.attributedText = titleString 49 | } 50 | } 51 | } 52 | } 53 | ``` 54 | 55 | ### Layout and view 56 | 57 | ```swift 58 | let layoutSpec = DemoLayoutSpec( 59 | model: DemoLayoutModel( 60 | image: UIImage(named: "ive")!, 61 | title: "Jonathan Ive is Apple’s chief design officer, reporting to CEO Tim Cook." 62 | ) 63 | ) 64 | 65 | let layout = layoutSpec.makeLayoutWith( 66 | boundingDimensions: CGSize(...).layoutDimensions 67 | ) 68 | 69 | let view = layout.makeView() 70 | ``` 71 | -------------------------------------------------------------------------------- /Docs/hello_world.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geor-kasapidi/ALLKit/a56dee5d4fae6acadd644afbb70f23b84ea6e073/Docs/hello_world.png -------------------------------------------------------------------------------- /Docs/list_view.md: -------------------------------------------------------------------------------- 1 | # Managing collection views 2 | 3 | [Basic concepts](basic_concepts.md) 4 | 5 | Instead of direct UICollectionView usage, ALLKit provides smart abstraction over: 6 | 7 | ```swift 8 | let adapter = CollectionViewAdapter<...>(layout: /* collectionViewLayout */) 9 | ``` 10 | 11 | Adapter is a collection view data source (delegate is up to you) and manages cells using yoga layout. 12 | 13 | All you need is just pass bouding dimensions: 14 | 15 | ```swift 16 | adapter.collectionView.frame = view.bounds 17 | 18 | adapter.set( 19 | boundingDimensions: CGSize(...).layoutDimensions 20 | ) 21 | ``` 22 | 23 | and data items: 24 | 25 | ```swift 26 | let items: [ListItem<...>] = ... 27 | 28 | adapter.set(items: items, animated: true) 29 | ``` 30 | 31 | That's it. And no direct calls of `performBatchUpdates` or `reloadData`. 32 | 33 | [ListItem](../Sources/ListKit/ListItem.swift) object represents cell and connects data model with UI: 34 | 35 | ```swift 36 | let item = ListItem<...>(id: Hashable, layoutSpec: LayoutSpec) 37 | ``` 38 | 39 | ID is needed to [calculate the changes](auto_diff.md) between the previous and current item lists. 40 | 41 | ### Swipe actions 42 | 43 | UICollectionView does not have built-in swiping support, like UITableView. With ALLKit, you can easily set up swipe actions (available by Extended subspec): 44 | 45 | ```swift 46 | let actions = SwipeActions(...) 47 | 48 | item.makeView = { layout, index -> UIView in 49 | actions.makeView(contentLayout: layout) 50 | } 51 | ``` 52 | 53 | ### Different sizes 54 | 55 | In complex lists, items can have different sizes — a full-width header, a multi-column grid, etc. For this purpose, ListItem has a special property: 56 | 57 | ```swift 58 | item.boundingDimensionsModifier = { w, h in 59 | return (w / 2, .nan) 60 | } 61 | ``` 62 | 63 | ### Interactive movement 64 | 65 | To enable this feature, you need to configure both the adapter and the elements: 66 | 67 | ```swift 68 | adapter.settings.allowInteractiveMovement = true 69 | 70 | adapter.collectionView.all_addGestureRecognizer { [weak self] (g: UILongPressGestureRecognizer) in 71 | self?.adapter.handleMoveGesture(g) 72 | } 73 | ... 74 | 75 | item.canMove = true 76 | item.didMove = { from, to in } 77 | ``` 78 | 79 | ### Custom collection view and cells 80 | 81 | In most cases, this feature is not used. But sometimes, for example, if you want to override cell layout attributes, it can be useful: 82 | 83 | ```swift 84 | final class CustomCell: UICollectionViewCell { 85 | override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) { 86 | super.apply(layoutAttributes) 87 | 88 | ... 89 | } 90 | } 91 | 92 | ... 93 | 94 | let adapter = CollectionViewAdapter() 95 | ``` 96 | 97 | ### ❗️❗️❗️ DO NOT ❗️❗️❗️ 98 | 99 | * ...modify cell's contentView directly. Adapter creates cell UI based on the spec that you specified in the ListItem object. 100 | * ...call collection view methods that are relevant to updating UI, such as `reloadData`, `performBatchUpdates`, etc. May the [AutoDiff](auto_diff.md) be with you. 101 | * ...setting the collection view dataSource. 102 | * ...enable prefetching in collection view. 103 | -------------------------------------------------------------------------------- /Docs/string_builder.md: -------------------------------------------------------------------------------- 1 | # Building attributed strings 2 | 3 | NSAttributedString = text + display rules. 4 | 5 | This is very important entity, but the API provided by iOS SDK is not very convenient. 6 | 7 | Typical example: 8 | 9 | ```swift 10 | let ps = NSMutableParagraphStyle() 11 | ps.alignment = .center 12 | ps.lineSpacing = 4 13 | ps.lineBreakMode = .byTruncatingMiddle 14 | 15 | let s = NSAttributedString( 16 | string: "some text", 17 | attributes: [ 18 | .font: UIFont.systemFont(ofSize: 15), 19 | .foregroundColor: UIColor.black, 20 | .paragraphStyle: ps 21 | ] 22 | ) 23 | ``` 24 | 25 | Separation of properties into attributes and paragraph style complicates the creation of NSAttributedStrings. 26 | 27 | [AttributedStringBuilder](../Sources/StringBuilder/AttributedStringBuilder.swift) solves this problem (available by StringBuilder subspec): 28 | 29 | ```swift 30 | let s = "some text".attributed() 31 | .font(UIFont.systemFont(ofSize: 15)) 32 | .foregroundColor(UIColor.black) 33 | .alignment(.center) 34 | .lineSpacing(4) 35 | .lineBreakMode(.byTruncatingMiddle) 36 | .make() // or makeMutable() 37 | ``` 38 | -------------------------------------------------------------------------------- /Docs/target_actions.md: -------------------------------------------------------------------------------- 1 | # Replacing target-actions with closures 2 | 3 | ObjC target-action API has a big limitation - an object with a method marked with the @objc attribute is required. 4 | 5 | Sometimes there is no such object. For example, when you create a view on the fly in a layout node: 6 | 7 | ```swift 8 | final class SomeLayoutSpec: LayoutSpec { 9 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNode { 10 | return LayoutNode(...) { (view: UIView, _) in 11 | // add tap gesture recognizer 12 | } 13 | } 14 | } 15 | ``` 16 | 17 | It would be nice to have an API with closures in this place. And ALLKit provides closure support for gestures and controls (available by Extended subspec): 18 | 19 | * **Gestures** 20 | 21 | ```swift 22 | view.all_addGestureRecognizer { (_: UITapGestureRecognizer) in 23 | 24 | } 25 | ``` 26 | 27 | Method `all_addGestureRecognizer` is generic - you can specify any UIGestureRecognizer subclass. 28 | 29 | * **Controls** 30 | 31 | ```swift 32 | let button = UIButton(type: .system) 33 | 34 | button.all_setEventHandler(for: .touchUpInside) { 35 | 36 | } 37 | ``` 38 | 39 | Now you have all power of closures when using gestures or control events. 40 | 41 | ```swift 42 | final class SomeLayoutSpec: LayoutSpec { 43 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 44 | return LayoutNode(...) { (view: UIView, _) in 45 | view.all_addGestureRecognizer({ (_: UITapGestureRecognizer) in 46 | // wow! swift closure! 47 | }) 48 | } 49 | } 50 | } 51 | ``` 52 | -------------------------------------------------------------------------------- /Docs/view_recycling.md: -------------------------------------------------------------------------------- 1 | # Reusable views 2 | 3 | Let's start with an example: 4 | 5 | ```swift 6 | struct SomeModel { 7 | let foo: Bool 8 | } 9 | 10 | final class SomeLayoutSpec: ModelLayoutSpec { 11 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 12 | if model.foo { 13 | return LayoutNode { (imageView: UIImageView, _) in 14 | 15 | } 16 | } else { 17 | return LayoutNode { (label: UILabel, _) in 18 | 19 | } 20 | } 21 | } 22 | } 23 | 24 | ... 25 | 26 | let boundingDimensions = CGSize(width: UIScreen.main.bounds.width, height: .nan).layoutDimensions 27 | 28 | let layout1 = SomeLayoutSpec(model: SomeModel(foo: true)).makeLayoutWith(boundingDimensions: boundingDimensions) 29 | let layout2 = SomeLayoutSpec(model: SomeModel(foo: false)).makeLayoutWith(boundingDimensions: boundingDimensions) 30 | 31 | let view = UIView() 32 | 33 | layout1.setup(in: view) 34 | 35 | layout2.setup(in: view) 36 | ``` 37 | 38 | The component produces a different set of views depending on the data model. When layout2 is applied to a view, nothing happens because the view has a UIImageView as a subview, and the layout requires a UILabel. 39 | 40 | Hence the rule: 41 | 42 | **If you want to reuse views, the output of the layouts must be the same**. Views created by different layouts should have the same order, count and types. 43 | 44 | Or just create a view from scratch without reuse. But in this case, you will not be able to [animate](animations.md) the changes. 45 | 46 | Reuse is a good optimization because you can divide view configuration into one-time and multiple actions: 47 | 48 | ```swift 49 | let imageNode = LayoutNode(...) { (imageView: UIImageView, isNew) in 50 | if isNew /* if view is only created */ { 51 | imageView.contentMode = .scaleAspectFill 52 | imageView.backgroundColor = UIColor.lightGray 53 | } 54 | 55 | imageView.image = model.image 56 | } 57 | ``` 58 | 59 | Since the above process is not automatic, manual layout should be done carefully. 60 | 61 | Fortunately, when using the CollectionViewAdapter you should not think about how the layout is applied. And you can create specs in any convenient way. 62 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'cocoapods' 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.1) 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 | algoliasearch (1.27.1) 11 | httpclient (~> 2.8, >= 2.8.3) 12 | json (>= 1.5.1) 13 | atomos (0.1.3) 14 | claide (1.0.3) 15 | cocoapods (1.8.3) 16 | activesupport (>= 4.0.2, < 5) 17 | claide (>= 1.0.2, < 2.0) 18 | cocoapods-core (= 1.8.3) 19 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 20 | cocoapods-downloader (>= 1.2.2, < 2.0) 21 | cocoapods-plugins (>= 1.0.0, < 2.0) 22 | cocoapods-search (>= 1.0.0, < 2.0) 23 | cocoapods-stats (>= 1.0.0, < 2.0) 24 | cocoapods-trunk (>= 1.4.0, < 2.0) 25 | cocoapods-try (>= 1.1.0, < 2.0) 26 | colored2 (~> 3.1) 27 | escape (~> 0.0.4) 28 | fourflusher (>= 2.3.0, < 3.0) 29 | gh_inspector (~> 1.0) 30 | molinillo (~> 0.6.6) 31 | nap (~> 1.0) 32 | ruby-macho (~> 1.4) 33 | xcodeproj (>= 1.11.1, < 2.0) 34 | cocoapods-core (1.8.3) 35 | activesupport (>= 4.0.2, < 6) 36 | algoliasearch (~> 1.0) 37 | concurrent-ruby (~> 1.1) 38 | fuzzy_match (~> 2.0.4) 39 | nap (~> 1.0) 40 | cocoapods-deintegrate (1.0.4) 41 | cocoapods-downloader (1.2.2) 42 | cocoapods-plugins (1.0.0) 43 | nap 44 | cocoapods-search (1.0.0) 45 | cocoapods-stats (1.1.0) 46 | cocoapods-trunk (1.4.1) 47 | nap (>= 0.8, < 2.0) 48 | netrc (~> 0.11) 49 | cocoapods-try (1.1.0) 50 | colored2 (3.1.2) 51 | concurrent-ruby (1.1.5) 52 | escape (0.0.4) 53 | fourflusher (2.3.1) 54 | fuzzy_match (2.0.4) 55 | gh_inspector (1.1.3) 56 | httpclient (2.8.3) 57 | i18n (0.9.5) 58 | concurrent-ruby (~> 1.0) 59 | json (2.2.0) 60 | minitest (5.12.2) 61 | molinillo (0.6.6) 62 | nanaimo (0.2.6) 63 | nap (1.1.0) 64 | netrc (0.11.0) 65 | ruby-macho (1.4.0) 66 | thread_safe (0.3.6) 67 | tzinfo (1.2.5) 68 | thread_safe (~> 0.1) 69 | xcodeproj (1.12.0) 70 | CFPropertyList (>= 2.3.3, < 4.0) 71 | atomos (~> 0.1.3) 72 | claide (>= 1.0.2, < 2.0) 73 | colored2 (~> 3.1) 74 | nanaimo (~> 0.2.6) 75 | 76 | PLATFORMS 77 | ruby 78 | 79 | DEPENDENCIES 80 | cocoapods 81 | 82 | BUNDLED WITH 83 | 2.0.2 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2019 Geor Kasapidi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Nuke.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'Nuke' 3 | s.version = '8.3.0' 4 | s.summary = 'A powerful image loading and caching system' 5 | s.description = <<-EOS 6 | A powerful image loading and caching system which makes simple tasks like loading images into views extremely simple, while also supporting more advanced features for more demanding apps. 7 | EOS 8 | 9 | s.homepage = 'https://github.com/kean/Nuke' 10 | s.license = 'MIT' 11 | s.author = 'Alexander Grebenyuk' 12 | s.social_media_url = 'https://twitter.com/a_grebenyuk' 13 | s.source = { :git => 'https://github.com/kean/Nuke.git', :tag => s.version.to_s } 14 | 15 | s.ios.deployment_target = '10.0' 16 | s.swift_version = '5' 17 | s.source_files = 'Sources/**/*' 18 | end 19 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | install! 'cocoapods', integrate_targets: false, share_schemes_for_development_pods: true, :disable_input_output_paths => true 2 | 3 | platform :ios, '10.0' 4 | 5 | use_modular_headers! 6 | inhibit_all_warnings! 7 | 8 | target 'Demos' do 9 | pod 'PinIt' 10 | pod 'Nuke', :podspec => 'Nuke.podspec' 11 | pod 'ALLKit', :path => './', :appspecs => ['Demos'], :testspecs => ['Tests'] 12 | 13 | target 'DemosTests' do 14 | inherit! :search_paths 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - ALLKit (1.3): 3 | - ALLKit/Extended (= 1.3) 4 | - ALLKit/StringBuilder (= 1.3) 5 | - ALLKit/Demos (1.3): 6 | - Nuke 7 | - PinIt 8 | - ALLKit/Diff (1.3) 9 | - ALLKit/Extended (1.3): 10 | - ALLKit/ListKit 11 | - ALLKit/Layout (1.3): 12 | - ALLKit/SwiftYoga 13 | - ALLKit/ListKit (1.3): 14 | - ALLKit/Diff 15 | - ALLKit/Layout 16 | - ALLKit/StringBuilder (1.3) 17 | - ALLKit/SwiftYoga (1.3): 18 | - Yoga (= 1.14) 19 | - ALLKit/Tests (1.3): 20 | - ALLKit/Demos 21 | - Nuke (8.3.0) 22 | - PinIt (1.0) 23 | - Yoga (1.14.0) 24 | 25 | DEPENDENCIES: 26 | - ALLKit (from `./`) 27 | - ALLKit/Demos (from `./`) 28 | - ALLKit/Tests (from `./`) 29 | - Nuke (from `Nuke.podspec`) 30 | - PinIt 31 | 32 | SPEC REPOS: 33 | trunk: 34 | - PinIt 35 | - Yoga 36 | 37 | EXTERNAL SOURCES: 38 | ALLKit: 39 | :path: "./" 40 | Nuke: 41 | :podspec: Nuke.podspec 42 | 43 | SPEC CHECKSUMS: 44 | ALLKit: 54d1ad1712ebe4d887bcf97858b92a32e80ff0c8 45 | Nuke: a98a5e8b3fa2975deba0d813a18b9344dcda0d94 46 | PinIt: 4590ce0526181463ff7b93eb2ff2fe66fc230999 47 | Yoga: cff67a400f6b74dc38eb0bad4f156673d9aa980c 48 | 49 | PODFILE CHECKSUM: d71c6c9aa1cf0167559d3cda7e2df1a07a13db52 50 | 51 | COCOAPODS: 1.8.3 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ALLKit [![Build Status](https://travis-ci.org/geor-kasapidi/ALLKit.svg?branch=master)](https://travis-ci.org/geor-kasapidi/ALLKit) ![PodL](https://img.shields.io/cocoapods/l/ALLKit.svg) ![PodV](https://img.shields.io/cocoapods/v/ALLKit.svg) ![Swift](https://img.shields.io/badge/swift-5.1-orange.svg) ![iOS](https://img.shields.io/badge/iOS-9+-blue.svg) 2 | 3 | A declarative data-driven framework for rapid development of smooth UI 4 | 5 | * **Stable and safe** - production ready solution used in some popular apps 6 | * **Easy to use** - simple and clean API using modern Swift features like function builder 7 | * **Lightweight** - 1K lines of code that greatly simplify development 8 | * **Modular** - use only components you need (see [podspec](ALLKit.podspec)) 9 | 10 | ## Features 11 | 12 | * Powerful abstraction over UICollectionView with automatic cells and views management 13 | * Layout calculation in background using [flexbox](https://yogalayout.com) 14 | * Flat view hierarchy with reduced number of subviews 15 | 16 | ## Installation 17 | 18 | `pod 'ALLKit'` 19 | 20 | ## How to use 21 | 22 | Check out the demo project (run `bundle exec pod install` and open Pods/Pods.xcodeproj) and read the docs: 23 | 24 | * [Basic concepts](Docs/basic_concepts.md) 25 | * [Spec example](Docs/hello_world.md) 26 | * [Managing collection views](Docs/list_view.md) 27 | * [Reusable views](Docs/view_recycling.md) 28 | * [Building attributed strings](Docs/string_builder.md) 29 | * [Async text rendering](Docs/async_text.md) 30 | * [Replacing target-actions with closures](Docs/target_actions.md) 31 | * [Animations](Docs/animations.md) 32 | * [AutoDiff](Docs/auto_diff.md) 33 | 34 | ## Apps 35 | 36 | * **FantLab** - [Source code](https://github.com/FantLab/FantLab-iOS), [AppStore page](https://itunes.apple.com/ru/app/fantlab/id1444604860?mt=8) 37 | 38 | ## Demo screenshots 39 | 40 | ![Screens](Docs/allkit_screens.png) 41 | -------------------------------------------------------------------------------- /Sources/Diff/Diff.swift: -------------------------------------------------------------------------------- 1 | public protocol Diffable: Equatable { 2 | associatedtype IdType: Hashable 3 | 4 | var diffId: IdType { get } 5 | } 6 | 7 | public enum Diff { 8 | public enum Change: Hashable { 9 | public typealias Index = Int 10 | 11 | case delete(Index) 12 | case insert(Index) 13 | case update(Index, Index) 14 | case move(Index, Index) 15 | } 16 | 17 | public typealias Changes = [Change] 18 | 19 | public static func between(_ oldItems: [T], and newItems: [T]) -> Changes { 20 | between( 21 | oldItems: oldItems, 22 | newItems: newItems, 23 | getId: { $0.diffId }, 24 | isEqual: { $0 == $1 } 25 | ) 26 | } 27 | 28 | public static func between(_ oldItems: [T], and newItems: [T]) -> Changes { 29 | between( 30 | oldItems: oldItems, 31 | newItems: newItems, 32 | getId: { $0 }, 33 | isEqual: { $0 == $1 } 34 | ) 35 | } 36 | 37 | // MARK: - Private 38 | 39 | private final class Entry { 40 | var newCount: Int = 0 41 | var oldIndices: ArraySlice = [] 42 | } 43 | 44 | private struct Record { 45 | let entry: Entry 46 | var reference: Int? 47 | } 48 | 49 | private static func between(oldItems: [ModelType], 50 | newItems: [ModelType], 51 | getId: (ModelType) -> IdType, 52 | isEqual: (ModelType, ModelType) -> Bool) -> Changes { 53 | if oldItems.isEmpty && newItems.isEmpty { 54 | return [] 55 | } 56 | 57 | if oldItems.isEmpty { 58 | return newItems.indices.map(Change.insert) 59 | } 60 | 61 | if newItems.isEmpty { 62 | return oldItems.indices.map(Change.delete) 63 | } 64 | 65 | // ------------------------------------------------------- 66 | 67 | var table = [IdType: Entry]() 68 | 69 | var newRecords = newItems.map { item -> Record in 70 | let id = getId(item) 71 | 72 | let entry = table[id] ?? Entry() 73 | entry.newCount += 1 74 | table[id] = entry 75 | 76 | return Record(entry: entry, reference: nil) 77 | } 78 | 79 | var oldRecords = oldItems.enumerated().map { (index, item) -> Record in 80 | let id = getId(item) 81 | 82 | let entry = table[id] ?? Entry() 83 | entry.oldIndices.append(index) 84 | table[id] = entry 85 | 86 | return Record(entry: entry, reference: nil) 87 | } 88 | 89 | table.removeAll() 90 | 91 | // ------------------------------------------------------- 92 | 93 | newRecords.enumerated().forEach { (newIndex, newRecord) in 94 | let entry = newRecord.entry 95 | 96 | guard entry.newCount > 0, let oldIndex = entry.oldIndices.popFirst() else { 97 | return 98 | } 99 | 100 | newRecords[newIndex].reference = oldIndex 101 | oldRecords[oldIndex].reference = newIndex 102 | } 103 | 104 | // ------------------------------------------------------- 105 | 106 | var changes: [Change] = [] 107 | 108 | var offset = 0 109 | 110 | let deleteOffsets = oldRecords.enumerated().map { (oldIndex, oldRecord) -> Int in 111 | let deleteOffset = offset 112 | 113 | if oldRecord.reference == nil { 114 | changes.append(.delete(oldIndex)) 115 | 116 | offset += 1 117 | } 118 | 119 | return deleteOffset 120 | } 121 | 122 | // ------------------------------------------------------- 123 | 124 | offset = 0 125 | 126 | newRecords.enumerated().forEach { (newIndex, newRecord) in 127 | guard let oldIndex = newRecord.reference else { 128 | changes.append(.insert(newIndex)) 129 | 130 | offset += 1 131 | 132 | return 133 | } 134 | 135 | let deleteOffset = deleteOffsets[oldIndex] 136 | let insertOffset = offset 137 | 138 | let moved = (oldIndex - deleteOffset + insertOffset) != newIndex 139 | let updated = !isEqual(newItems[newIndex], oldItems[oldIndex]) 140 | 141 | if updated { 142 | changes.append(.update(oldIndex, oldIndex)) 143 | } else if moved { 144 | changes.append(.move(oldIndex, newIndex)) 145 | } 146 | } 147 | 148 | // ------------------------------------------------------- 149 | 150 | return changes 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Sources/Extended/AsyncLabel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public final class AsyncLabel: UIView { 5 | private let renderQueue = DispatchQueue(label: "ALLKit.AsyncLabel.renderQueue") 6 | 7 | public var stringDrawing: AttributedStringDrawing? { 8 | willSet { 9 | assert(Thread.isMainThread) 10 | } 11 | didSet { 12 | render(stringDrawing, bounds.size) 13 | } 14 | } 15 | 16 | private func render(_ stringDrawing: AttributedStringDrawing?, _ size: CGSize) { 17 | renderQueue.async { [weak self] in 18 | let image = stringDrawing?.draw(with: size) 19 | 20 | DispatchQueue.main.async { 21 | self?.layer.contents = image?.cgImage 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Extended/AttributedStringDrawing.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public struct AttributedStringDrawing: SizeProvider { 5 | public let string: NSAttributedString 6 | public let options: NSStringDrawingOptions 7 | public let context: NSStringDrawingContext? 8 | 9 | public func calculateSize(boundedBy dimensions: LayoutDimensions) -> CGSize { 10 | string.boundingRect(with: dimensions.size, options: options, context: context).size 11 | } 12 | 13 | public func draw(with size: CGSize) -> UIImage? { 14 | UIGraphicsBeginImageContextWithOptions(size, false, 0) 15 | 16 | guard UIGraphicsGetCurrentContext() != nil else { 17 | return nil 18 | } 19 | 20 | defer { UIGraphicsEndImageContext() } 21 | 22 | string.draw(with: CGRect(origin: .zero, size: size), 23 | options: options, 24 | context: context) 25 | 26 | return UIGraphicsGetImageFromCurrentImageContext() 27 | } 28 | } 29 | 30 | extension NSAttributedString { 31 | public func drawing(options: NSStringDrawingOptions = .usesLineFragmentOrigin, 32 | context: NSStringDrawingContext? = nil) -> AttributedStringDrawing { 33 | return AttributedStringDrawing( 34 | string: self, 35 | options: options, 36 | context: context 37 | ) 38 | } 39 | } 40 | 41 | extension NSAttributedString: SizeProvider { 42 | public func calculateSize(boundedBy dimensions: LayoutDimensions) -> CGSize { 43 | return drawing().calculateSize(boundedBy: dimensions) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Extended/SwipeView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public struct SwipeAction { 5 | public typealias ViewSetup = (UIView, @escaping (Bool) -> Void) -> Void // view + close(animated) 6 | 7 | public let layoutSpec: LayoutSpec 8 | public let setup: ViewSetup 9 | 10 | public init(layoutSpec: LayoutSpec, setup: @escaping ViewSetup) { 11 | self.layoutSpec = layoutSpec 12 | self.setup = setup 13 | } 14 | } 15 | 16 | public struct SwipeActions { 17 | public let list: [SwipeAction] 18 | public let size: CGFloat 19 | 20 | public init?(_ list: [SwipeAction], size: CGFloat = 96) { 21 | assert(size > 0) 22 | 23 | guard size.isNormal, !list.isEmpty else { return nil } 24 | 25 | self.list = list 26 | self.size = size 27 | } 28 | } 29 | 30 | public protocol ISwipeView { 31 | func open(animated: Bool) 32 | func close(animated: Bool) 33 | } 34 | 35 | extension SwipeView: ISwipeView { 36 | func open(animated: Bool) { 37 | update(offset: 1, animated: animated) 38 | } 39 | 40 | func close(animated: Bool) { 41 | update(offset: 0, animated: animated) 42 | } 43 | } 44 | 45 | extension SwipeActions { 46 | public func makeView(contentLayout: Layout) -> UIView & ISwipeView { 47 | SwipeView(contentLayout: contentLayout, actions: self) 48 | } 49 | } 50 | 51 | final class SwipeView: UIView, UIGestureRecognizerDelegate { 52 | private let contentView = UIView() 53 | private let buttonWidth: CGFloat 54 | private var buttons: [UIView] = [] 55 | 56 | init(contentLayout: Layout, actions: SwipeActions) { 57 | buttonWidth = actions.size 58 | 59 | super.init(frame: CGRect(origin: .zero, size: contentLayout.size)) 60 | 61 | clipsToBounds = true 62 | 63 | do { 64 | let boundingSize = CGSize(width: buttonWidth, height: contentLayout.size.height) 65 | 66 | actions.list.reversed().forEach { action in 67 | let layout = action.layoutSpec.makeLayoutWith(boundingDimensions: boundingSize.layoutDimensions) 68 | 69 | let view = UIView(frame: CGRect( 70 | x: 0, 71 | y: 0, 72 | width: buttonWidth, 73 | height: contentLayout.size.height 74 | )) 75 | view.clipsToBounds = true 76 | view.addSubview(layout.makeView()) 77 | action.setup(view, { [weak self] animated in 78 | self?.update(offset: 0, animated: animated) 79 | }) 80 | addSubview(view) 81 | 82 | buttons.append(view) 83 | } 84 | } 85 | 86 | do { 87 | contentView.addSubview(contentLayout.makeView()) 88 | 89 | contentView.isExclusiveTouch = true 90 | 91 | addSubview(contentView) 92 | 93 | contentView.all_addGestureRecognizer({ [weak self] (pan: UIPanGestureRecognizer) in 94 | self?.didUpdateGestureState(pan) 95 | }).delegate = self 96 | 97 | contentView.all_addGestureRecognizer({ [weak self] (_: UITapGestureRecognizer) in 98 | self?.update(offset: 0, animated: true) 99 | }).delegate = self 100 | } 101 | } 102 | 103 | required init?(coder aDecoder: NSCoder) { 104 | fatalError() 105 | } 106 | 107 | // MARK: - 108 | 109 | private var location: CGFloat = 0 110 | 111 | private func didUpdateGestureState(_ pan: UIPanGestureRecognizer) { 112 | switch pan.state { 113 | case .began: 114 | location = contentView.frame.minX 115 | case .changed: 116 | let translation = pan.translation(in: contentView).x 117 | 118 | let w = CGFloat(buttons.count) * buttonWidth 119 | 120 | let x = max(0, -(translation + location)) 121 | 122 | if x < w { 123 | update(offset: x/w, animated: false) 124 | } else { 125 | update(offset: pow(CGFloat(M_E), 1-w/x), animated: false) 126 | } 127 | default: 128 | let translation = pan.translation(in: contentView).x 129 | 130 | update(offset: offset > 1 ? 1 : (translation > 0 ? 0 : 1), animated: true) 131 | } 132 | } 133 | 134 | override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 135 | if let tap = gestureRecognizer as? UITapGestureRecognizer, tap.view === contentView { 136 | return offset > 0 137 | } 138 | 139 | if let pan = gestureRecognizer as? UIPanGestureRecognizer, pan.view === contentView { 140 | let translation = pan.translation(in: contentView) 141 | 142 | return pan.numberOfTouches == 1 && abs(translation.y) < abs(translation.x) 143 | } 144 | 145 | return super.gestureRecognizerShouldBegin(gestureRecognizer) 146 | } 147 | 148 | // MARK: - 149 | 150 | private func update(offset value: CGFloat, animated: Bool) { 151 | guard (value.isZero || value.isNormal), value >= 0, value != offset else { return } 152 | 153 | offset = value 154 | 155 | setNeedsLayout() 156 | 157 | if animated { 158 | UIView.animate( 159 | withDuration: 0.2, 160 | delay: 0, 161 | options: [.allowUserInteraction, .beginFromCurrentState], 162 | animations: layoutIfNeeded, 163 | completion: nil 164 | ) 165 | } else { 166 | UIView.performWithoutAnimation(layoutIfNeeded) 167 | } 168 | } 169 | 170 | private var offset: CGFloat = 0 171 | 172 | // MARK: - 173 | 174 | override func layoutSubviews() { 175 | super.layoutSubviews() 176 | 177 | let bounds = self.bounds 178 | 179 | guard !bounds.isEmpty else { return } 180 | 181 | let viewWidth = buttonWidth * offset 182 | 183 | contentView.frame = bounds.offsetBy( 184 | dx: -CGFloat(buttons.count) * viewWidth, 185 | dy: 0 186 | ) 187 | 188 | buttons.enumerated().forEach { (index, view) in 189 | view.frame = CGRect( 190 | x: bounds.width - CGFloat(index + 1) * viewWidth, 191 | y: 0, 192 | width: viewWidth, 193 | height: bounds.height 194 | ) 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /Sources/Extended/UIActions.swift: -------------------------------------------------------------------------------- 1 | import ObjectiveC 2 | import Foundation 3 | import UIKit 4 | 5 | private final class AssociatedProxyTable { 6 | private let key: UnsafeRawPointer 7 | 8 | init(key: UnsafeRawPointer) { 9 | self.key = key 10 | } 11 | 12 | subscript(holder: KeyType) -> ValueType? { 13 | get { objc_getAssociatedObject(holder, key) as? ValueType } 14 | set { objc_setAssociatedObject(holder, key, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } 15 | } 16 | } 17 | 18 | extension UIControl { 19 | private final class Handler: NSObject { 20 | let action: () -> Void 21 | 22 | init(action: @escaping () -> Void) { 23 | self.action = action 24 | } 25 | 26 | @objc 27 | func invoke() { 28 | action() 29 | } 30 | } 31 | 32 | private struct Storage { 33 | private static var key = 0 34 | 35 | static let table = AssociatedProxyTable(key: &key) 36 | } 37 | 38 | public func all_setEventHandler(for controlEvents: UIControl.Event, _ action: (() -> Void)?) { 39 | let handlers = Storage.table[self] ?? NSMutableDictionary() 40 | if let currentHandler = handlers[controlEvents.rawValue] { 41 | removeTarget(currentHandler, action: #selector(Handler.invoke), for: controlEvents) 42 | handlers[controlEvents.rawValue] = nil 43 | } 44 | if let newAction = action { 45 | let newHandler = Handler(action: newAction) 46 | addTarget(newHandler, action: #selector(Handler.invoke), for: controlEvents) 47 | handlers[controlEvents.rawValue] = newHandler 48 | } 49 | Storage.table[self] = handlers 50 | } 51 | } 52 | 53 | extension UIView { 54 | private final class Handler: NSObject { 55 | private let action: (GestureType) -> Void 56 | 57 | init(action: @escaping (GestureType) -> Void) { 58 | self.action = action 59 | } 60 | 61 | @objc 62 | func invoke(gesture: UIGestureRecognizer) { 63 | (gesture as? GestureType).flatMap { 64 | action($0) 65 | } 66 | } 67 | } 68 | 69 | private struct Storage { 70 | private static var key = 0 71 | 72 | static let table = AssociatedProxyTable(key: &key) 73 | } 74 | 75 | @discardableResult 76 | public func all_addGestureRecognizer(_ action: @escaping (GestureType) -> Void) -> GestureType { 77 | let handler = Handler(action: action) 78 | let gesture = GestureType(target: handler, action: #selector(Handler.invoke(gesture:))) 79 | Storage.table[gesture] = handler 80 | addGestureRecognizer(gesture) 81 | return gesture 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/Layout/Calculator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import yoga 4 | 5 | protocol LayoutCalculator: class { 6 | func makeLayoutBy(spec: LayoutSpec, 7 | boundingDimensions: LayoutDimensions, 8 | layoutDirection: UIUserInterfaceLayoutDirection) -> Layout 9 | } 10 | 11 | final class FlatLayoutCalculator: LayoutCalculator { 12 | private struct ViewData { 13 | let frame: CGRect 14 | let factory: ViewFactory 15 | } 16 | 17 | private struct LayoutData { 18 | let size: CGSize 19 | let views: [ViewData] 20 | } 21 | 22 | private final class FlatLayout: Layout { 23 | let data: LayoutData 24 | 25 | init(_ data: LayoutData) { 26 | self.data = data 27 | } 28 | 29 | // MARK: - Layout 30 | 31 | var size: CGSize { 32 | return data.size 33 | } 34 | 35 | func makeContentIn(view: UIView) { 36 | if view.subviews.isEmpty { 37 | data.views.forEach { viewData in 38 | let subview = viewData.factory.makeView() 39 | view.addSubview(subview) 40 | subview.frame = viewData.frame 41 | viewData.factory.config(view: subview, isNew: true) 42 | } 43 | } else { 44 | view.subviews.enumerated().forEach { (index, subview) in 45 | guard data.views.indices.contains(index) else { 46 | return 47 | } 48 | 49 | let viewData = data.views[index] 50 | 51 | subview.frame = viewData.frame 52 | viewData.factory.config(view: subview, isNew: false) 53 | } 54 | } 55 | } 56 | } 57 | 58 | // MARK: - 59 | 60 | func makeLayoutBy(spec: LayoutSpec, 61 | boundingDimensions: LayoutDimensions, 62 | layoutDirection: UIUserInterfaceLayoutDirection) -> Layout { 63 | let node = spec.makeNodeWith(boundingDimensions: boundingDimensions).layoutNode 64 | 65 | node.yoga.calculateLayout( 66 | width: Float(boundingDimensions.width.value ?? .nan), 67 | height: Float(boundingDimensions.height.value ?? .nan), 68 | parentDirection: layoutDirection == .rightToLeft ? .RTL : .LTR 69 | ) 70 | 71 | let frame = node.yoga.frame 72 | var views: [ViewData] = [] 73 | 74 | traverse(node: node, offset: frame.origin, views: &views) 75 | 76 | return FlatLayout(LayoutData(size: frame.size, views: views)) 77 | } 78 | 79 | // MARK: - 80 | 81 | private func traverse(node: LayoutNode, offset: CGPoint, views: inout [ViewData]) { 82 | let frame = node.yoga.frame.offsetBy(dx: offset.x, dy: offset.y) 83 | 84 | if let viewFactory = node.viewFactory { 85 | views.append(ViewData( 86 | frame: frame, 87 | factory: viewFactory 88 | )) 89 | } 90 | 91 | node.children.forEach { 92 | traverse(node: $0, offset: frame.origin, views: &views) 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/Layout/Dimensions.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | 3 | public struct LayoutDimension: Equatable { 4 | public let value: T? 5 | 6 | public static func == (lhs: LayoutDimension, rhs: LayoutDimension) -> Bool { 7 | return lhs.value == nil && rhs.value == nil || lhs.value == rhs.value 8 | } 9 | } 10 | 11 | extension FloatingPoint { 12 | public var layoutDimension: LayoutDimension { 13 | LayoutDimension(value: (Self.ulpOfOne...Self.greatestFiniteMagnitude).contains(self) ? self : nil) 14 | } 15 | } 16 | 17 | public struct LayoutDimensions: Equatable { 18 | public let width: LayoutDimension 19 | public let height: LayoutDimension 20 | 21 | public init(width: LayoutDimension, height: LayoutDimension) { 22 | self.width = width 23 | self.height = height 24 | } 25 | } 26 | 27 | extension LayoutDimensions { 28 | public typealias Modifier = (T, T) -> (T, T) 29 | 30 | public func modify(_ fn: Modifier) -> LayoutDimensions { 31 | let (w, h) = fn(width.value ?? .nan, height.value ?? .nan) 32 | 33 | return LayoutDimensions( 34 | width: w.layoutDimension, 35 | height: h.layoutDimension 36 | ) 37 | } 38 | } 39 | 40 | extension LayoutDimensions where T == CGFloat { 41 | public var size: CGSize { 42 | return CGSize( 43 | width: width.value ?? .greatestFiniteMagnitude, 44 | height: height.value ?? .greatestFiniteMagnitude 45 | ) 46 | } 47 | } 48 | 49 | extension CGSize { 50 | public var layoutDimensions: LayoutDimensions { 51 | LayoutDimensions( 52 | width: width.layoutDimension, 53 | height: height.layoutDimension 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Layout/FlexBox.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import yoga 4 | 5 | public enum FlexDirection { 6 | case column 7 | case columnReverse 8 | case row 9 | case rowReverse 10 | } 11 | 12 | public enum FlexWrap { 13 | case noWrap 14 | case wrap 15 | case wrapReverse 16 | } 17 | 18 | public enum FlexAlign { 19 | case flexStart 20 | case center 21 | case flexEnd 22 | case stretch 23 | case spaceBetween 24 | case spaceAround 25 | } 26 | 27 | public enum FlexJustify { 28 | case flexStart 29 | case center 30 | case flexEnd 31 | case spaceBetween 32 | case spaceAround 33 | case spaceEvenly 34 | } 35 | 36 | public enum FlexValue: ExpressibleByIntegerLiteral { 37 | public init(integerLiteral value: Int) { 38 | self = .point(CGFloat(value)) 39 | } 40 | 41 | case point(CGFloat) 42 | case percent(Int) 43 | } 44 | 45 | public enum FlexDimension { 46 | case size(FlexValue) 47 | case width(FlexValue) 48 | case height(FlexValue) 49 | } 50 | 51 | public enum FlexEdge { 52 | case all(FlexValue) 53 | case vertical(FlexValue) 54 | case horizontal(FlexValue) 55 | case top(FlexValue) 56 | case left(FlexValue) 57 | case bottom(FlexValue) 58 | case right(FlexValue) 59 | } 60 | 61 | extension Array where Element == FlexDimension { 62 | func apply(width: (FlexValue) -> Void, 63 | height: (FlexValue) -> Void) { 64 | forEach { 65 | switch $0 { 66 | case let .size(value): 67 | width(value) 68 | height(value) 69 | case let .width(value): 70 | width(value) 71 | case let .height(value): 72 | height(value) 73 | } 74 | } 75 | } 76 | } 77 | 78 | extension Array where Element == FlexEdge { 79 | func apply(top: (FlexValue) -> Void, 80 | left: (FlexValue) -> Void, 81 | bottom: (FlexValue) -> Void, 82 | right: (FlexValue) -> Void) { 83 | forEach { 84 | switch $0 { 85 | case let .all(value): 86 | top(value) 87 | left(value) 88 | bottom(value) 89 | right(value) 90 | case let .vertical(value): 91 | top(value) 92 | bottom(value) 93 | case let .horizontal(value): 94 | left(value) 95 | right(value) 96 | case let .top(value): 97 | top(value) 98 | case let .left(value): 99 | left(value) 100 | case let .bottom(value): 101 | bottom(value) 102 | case let .right(value): 103 | right(value) 104 | } 105 | } 106 | } 107 | } 108 | 109 | extension FlexValue { 110 | var ygValue: YGValue { 111 | switch self { 112 | case let .point(value): 113 | return YGValue(value: Float(value), unit: .point) 114 | case let .percent(value): 115 | return YGValue(value: Float(value), unit: .percent) 116 | } 117 | } 118 | } 119 | 120 | extension FlexDirection { 121 | var ygValue: YGFlexDirection { 122 | switch self { 123 | case .column: 124 | return .column 125 | case .columnReverse: 126 | return .columnReverse 127 | case .row: 128 | return .row 129 | case .rowReverse: 130 | return .rowReverse 131 | } 132 | } 133 | } 134 | 135 | extension FlexWrap { 136 | var ygValue: YGWrap { 137 | switch self { 138 | case .noWrap: 139 | return .noWrap 140 | case .wrap: 141 | return .wrap 142 | case .wrapReverse: 143 | return .wrapReverse 144 | } 145 | } 146 | } 147 | 148 | extension FlexJustify { 149 | var ygValue: YGJustify { 150 | switch self { 151 | case .center: 152 | return .center 153 | case .flexEnd: 154 | return .flexEnd 155 | case .flexStart: 156 | return .flexStart 157 | case .spaceAround: 158 | return .spaceAround 159 | case .spaceBetween: 160 | return .spaceBetween 161 | case .spaceEvenly: 162 | return .spaceEvenly 163 | } 164 | } 165 | } 166 | 167 | extension FlexAlign { 168 | var ygValue: YGAlign { 169 | switch self { 170 | case .center: 171 | return .center 172 | case .flexEnd: 173 | return .flexEnd 174 | case .flexStart: 175 | return .flexStart 176 | case .spaceAround: 177 | return .spaceAround 178 | case .spaceBetween: 179 | return .spaceBetween 180 | case .stretch: 181 | return .stretch 182 | } 183 | } 184 | } 185 | 186 | extension Yoga.Config { 187 | static let main = Yoga.Config(pointScale: UIScreen.main.scale) 188 | } 189 | 190 | public final class FlexBox { 191 | public typealias Setup = (FlexBox) -> FlexBox 192 | 193 | let yoga: Yoga.Node 194 | 195 | init(sizeProvider: SizeProvider?, setup: Setup?, config: Yoga.Config) { 196 | if let sizeProvider = sizeProvider { 197 | yoga = Yoga.Node(config: config, measureFunc: { 198 | sizeProvider.calculateSize(boundedBy: $0.layoutDimensions) 199 | }) 200 | } else { 201 | yoga = Yoga.Node(config: config, measureFunc: nil) 202 | } 203 | setup?(self) 204 | } 205 | 206 | public func isHidden(_ value: Bool) -> Self { 207 | yoga.display = value ? .flex : .none 208 | 209 | return self 210 | } 211 | 212 | public func isOverlay(_ value: Bool) -> Self { 213 | yoga.positionType = value ? .absolute : .relative 214 | 215 | return self 216 | } 217 | 218 | public func flex(_ value: CGFloat) -> Self { 219 | yoga.flex = Float(value) 220 | 221 | return self 222 | } 223 | 224 | public func flexGrow(_ value: CGFloat) -> Self { 225 | yoga.flexGrow = Float(value) 226 | 227 | return self 228 | } 229 | 230 | public func flexShrink(_ value: CGFloat) -> Self { 231 | yoga.flexShrink = Float(value) 232 | 233 | return self 234 | } 235 | 236 | public func aspectRatio(_ value: CGFloat) -> Self { 237 | yoga.aspectRatio = Float(value) 238 | 239 | return self 240 | } 241 | 242 | public func flexDirection(_ value: FlexDirection) -> Self { 243 | yoga.flexDirection = value.ygValue 244 | 245 | return self 246 | } 247 | 248 | public func flexWrap(_ value: FlexWrap) -> Self { 249 | yoga.flexWrap = value.ygValue 250 | 251 | return self 252 | } 253 | 254 | public func flexBasis(_ value: FlexValue) -> Self { 255 | yoga.flexBasis = value.ygValue 256 | 257 | return self 258 | } 259 | 260 | public func justifyContent(_ value: FlexJustify) -> Self { 261 | yoga.justifyContent = value.ygValue 262 | 263 | return self 264 | } 265 | 266 | public func alignContent(_ value: FlexAlign) -> Self { 267 | yoga.alignContent = value.ygValue 268 | 269 | return self 270 | } 271 | 272 | public func alignItems(_ value: FlexAlign) -> Self { 273 | yoga.alignItems = value.ygValue 274 | 275 | return self 276 | } 277 | 278 | public func alignSelf(_ value: FlexAlign) -> Self { 279 | yoga.alignSelf = value.ygValue 280 | 281 | return self 282 | } 283 | 284 | public func width(_ value: FlexValue) -> Self { 285 | yoga.width = value.ygValue 286 | 287 | return self 288 | } 289 | 290 | public func height(_ value: FlexValue) -> Self { 291 | yoga.height = value.ygValue 292 | 293 | return self 294 | } 295 | 296 | public func min(_ value: FlexDimension...) -> Self { 297 | value.apply( 298 | width: { yoga.minWidth = $0.ygValue }, 299 | height: { yoga.minHeight = $0.ygValue } 300 | ) 301 | 302 | return self 303 | } 304 | 305 | public func max(_ value: FlexDimension...) -> Self { 306 | value.apply( 307 | width: { yoga.maxWidth = $0.ygValue }, 308 | height: { yoga.maxHeight = $0.ygValue } 309 | ) 310 | 311 | return self 312 | } 313 | 314 | public func position(_ value: FlexEdge...) -> Self { 315 | value.apply( 316 | top: { yoga.top = $0.ygValue }, 317 | left: { yoga.left = $0.ygValue }, 318 | bottom: { yoga.bottom = $0.ygValue }, 319 | right: { yoga.right = $0.ygValue } 320 | ) 321 | 322 | return self 323 | } 324 | 325 | public func margin(_ value: FlexEdge...) -> Self { 326 | value.apply( 327 | top: { yoga.marginTop = $0.ygValue }, 328 | left: { yoga.marginLeft = $0.ygValue }, 329 | bottom: { yoga.marginBottom = $0.ygValue }, 330 | right: { yoga.marginRight = $0.ygValue } 331 | ) 332 | 333 | return self 334 | } 335 | 336 | public func padding(_ value: FlexEdge...) -> Self { 337 | value.apply( 338 | top: { yoga.paddingTop = $0.ygValue }, 339 | left: { yoga.paddingLeft = $0.ygValue }, 340 | bottom: { yoga.paddingBottom = $0.ygValue }, 341 | right: { yoga.paddingRight = $0.ygValue } 342 | ) 343 | 344 | return self 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /Sources/Layout/Layout.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public protocol Layout: class { 5 | var size: CGSize { get } 6 | 7 | func makeContentIn(view: UIView) 8 | } 9 | 10 | extension Layout { 11 | public func setup(in view: UIView, at origin: CGPoint = .zero) { 12 | view.frame = CGRect(origin: origin, size: size) 13 | 14 | makeContentIn(view: view) 15 | } 16 | 17 | public func makeView() -> UIView { 18 | let view = UIView() 19 | setup(in: view) 20 | return view 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Layout/Node.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public protocol LayoutNodeConvertible { 5 | var layoutNode: LayoutNode { get } 6 | } 7 | 8 | extension LayoutNode: LayoutNodeConvertible { 9 | public var layoutNode: LayoutNode { 10 | self 11 | } 12 | } 13 | 14 | public final class LayoutNode { 15 | let children: [LayoutNode] 16 | let viewFactory: ViewFactory? 17 | let yoga: Yoga.Node 18 | 19 | public init(children: [LayoutNodeConvertible?] = [], 20 | _ layout: FlexBox.Setup? = nil, 21 | _ LayoutNodeConvertible: ((ViewType, Bool) -> Void)? = nil) { 22 | self.children = children.compactMap({ $0?.layoutNode }) 23 | viewFactory = LayoutNodeConvertible.flatMap(GenericViewFactory.init) 24 | yoga = FlexBox(sizeProvider: nil, setup: layout, config: .main).yoga 25 | self.children.forEach { yoga.add(child: $0.yoga) } 26 | } 27 | 28 | public init(sizeProvider: SizeProvider, 29 | _ layout: FlexBox.Setup? = nil, 30 | _ LayoutNodeConvertible: ((ViewType, Bool) -> Void)? = nil) { 31 | children = [] 32 | viewFactory = LayoutNodeConvertible.flatMap(GenericViewFactory.init) 33 | yoga = FlexBox(sizeProvider: sizeProvider, setup: layout, config: .main).yoga 34 | } 35 | } 36 | 37 | // ******************************************************* 38 | 39 | @_functionBuilder 40 | public struct LayoutBuilder { 41 | public static func buildBlock(_ content: T) -> T { 42 | content 43 | } 44 | 45 | public static func buildBlock(_ c0: C0, _ c1: C1) -> [LayoutNodeConvertible] where C0 : LayoutNodeConvertible, C1 : LayoutNodeConvertible { 46 | [c0, c1] 47 | } 48 | 49 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2) -> [LayoutNodeConvertible] where C0 : LayoutNodeConvertible, C1 : LayoutNodeConvertible, C2 : LayoutNodeConvertible { 50 | [c0, c1, c2] 51 | } 52 | 53 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3) -> [LayoutNodeConvertible] where C0 : LayoutNodeConvertible, C1 : LayoutNodeConvertible, C2 : LayoutNodeConvertible, C3 : LayoutNodeConvertible { 54 | [c0, c1, c2, c3] 55 | } 56 | 57 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4) -> [LayoutNodeConvertible] where C0 : LayoutNodeConvertible, C1 : LayoutNodeConvertible, C2 : LayoutNodeConvertible, C3 : LayoutNodeConvertible, C4 : LayoutNodeConvertible { 58 | [c0, c1, c2, c3, c4] 59 | } 60 | 61 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5) -> [LayoutNodeConvertible] where C0 : LayoutNodeConvertible, C1 : LayoutNodeConvertible, C2 : LayoutNodeConvertible, C3 : LayoutNodeConvertible, C4 : LayoutNodeConvertible, C5 : LayoutNodeConvertible { 62 | [c0, c1, c2, c3, c4, c5] 63 | } 64 | 65 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6) -> [LayoutNodeConvertible] where C0 : LayoutNodeConvertible, C1 : LayoutNodeConvertible, C2 : LayoutNodeConvertible, C3 : LayoutNodeConvertible, C4 : LayoutNodeConvertible, C5 : LayoutNodeConvertible, C6 : LayoutNodeConvertible { 66 | [c0, c1, c2, c3, c4, c5, c6] 67 | } 68 | 69 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7) -> [LayoutNodeConvertible] where C0 : LayoutNodeConvertible, C1 : LayoutNodeConvertible, C2 : LayoutNodeConvertible, C3 : LayoutNodeConvertible, C4 : LayoutNodeConvertible, C5 : LayoutNodeConvertible, C6 : LayoutNodeConvertible, C7 : LayoutNodeConvertible { 70 | [c0, c1, c2, c3, c4, c5, c6, c7] 71 | } 72 | 73 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8) -> [LayoutNodeConvertible] where C0 : LayoutNodeConvertible, C1 : LayoutNodeConvertible, C2 : LayoutNodeConvertible, C3 : LayoutNodeConvertible, C4 : LayoutNodeConvertible, C5 : LayoutNodeConvertible, C6 : LayoutNodeConvertible, C7 : LayoutNodeConvertible, C8 : LayoutNodeConvertible { 74 | [c0, c1, c2, c3, c4, c5, c6, c7, c8] 75 | } 76 | 77 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> [LayoutNodeConvertible] where C0 : LayoutNodeConvertible, C1 : LayoutNodeConvertible, C2 : LayoutNodeConvertible, C3 : LayoutNodeConvertible, C4 : LayoutNodeConvertible, C5 : LayoutNodeConvertible, C6 : LayoutNodeConvertible, C7 : LayoutNodeConvertible, C8 : LayoutNodeConvertible, C9 : LayoutNodeConvertible { 78 | [c0, c1, c2, c3, c4, c5, c6, c7, c8, c9] 79 | } 80 | } 81 | 82 | // ******************************************************* 83 | 84 | public struct LayoutNodeBuilder { 85 | public struct SizeProviderFlexBoxStep { 86 | let sizeProvider: SizeProvider 87 | let layout: FlexBox.Setup? 88 | 89 | public func view(_ config: @escaping (ViewType, Bool) -> Void) -> SizeProviderFlexBoxViewStep { 90 | SizeProviderFlexBoxViewStep(sizeProvider: sizeProvider, layout: layout, view: config) 91 | } 92 | } 93 | 94 | public struct SizeProviderFlexBoxViewStep { 95 | let sizeProvider: SizeProvider 96 | let layout: FlexBox.Setup? 97 | let view: (ViewType, Bool) -> Void 98 | } 99 | 100 | public struct FlexBoxStep { 101 | let layout: FlexBox.Setup? 102 | 103 | public func view(_ config: @escaping (ViewType, Bool) -> Void) -> FlexBoxViewStep { 104 | FlexBoxViewStep(layout: layout, view: config) 105 | } 106 | 107 | public func body(@LayoutBuilder block: () -> LayoutNodeConvertible) -> FlexBoxViewBodyStep { 108 | FlexBoxViewBodyStep(layout: layout, view: nil, children: [block()]) 109 | } 110 | 111 | public func body(@LayoutBuilder block: () -> [LayoutNodeConvertible]) -> FlexBoxViewBodyStep { 112 | FlexBoxViewBodyStep(layout: layout, view: nil, children: block()) 113 | } 114 | } 115 | 116 | public struct FlexBoxViewStep { 117 | let layout: FlexBox.Setup? 118 | let view: (ViewType, Bool) -> Void 119 | 120 | public func body(@LayoutBuilder block: () -> LayoutNodeConvertible) -> FlexBoxViewBodyStep { 121 | FlexBoxViewBodyStep(layout: layout, view: view, children: [block()]) 122 | } 123 | 124 | public func body(@LayoutBuilder block: () -> [LayoutNodeConvertible]) -> FlexBoxViewBodyStep { 125 | FlexBoxViewBodyStep(layout: layout, view: view, children: block()) 126 | } 127 | } 128 | 129 | public struct FlexBoxViewBodyStep { 130 | let layout: FlexBox.Setup? 131 | let view: ((ViewType, Bool) -> Void)? 132 | let children: [LayoutNodeConvertible] 133 | } 134 | 135 | // MARK: - 136 | 137 | public init() {} 138 | 139 | public func layout(_ setup: FlexBox.Setup? = nil) -> FlexBoxStep { 140 | FlexBoxStep(layout: setup) 141 | } 142 | 143 | public func layout(sizeProvider: SizeProvider, _ setup: FlexBox.Setup? = nil) -> SizeProviderFlexBoxStep { 144 | SizeProviderFlexBoxStep(sizeProvider: sizeProvider, layout: setup) 145 | } 146 | } 147 | 148 | // ******************************************************* 149 | 150 | extension LayoutNodeBuilder.SizeProviderFlexBoxStep: LayoutNodeConvertible { 151 | public var layoutNode: LayoutNode { 152 | LayoutNode(sizeProvider: sizeProvider, layout) 153 | } 154 | } 155 | 156 | extension LayoutNodeBuilder.SizeProviderFlexBoxViewStep: LayoutNodeConvertible { 157 | public var layoutNode: LayoutNode { 158 | LayoutNode(sizeProvider: sizeProvider, layout, view) 159 | } 160 | } 161 | 162 | extension LayoutNodeBuilder.FlexBoxStep: LayoutNodeConvertible { 163 | public var layoutNode: LayoutNode { 164 | LayoutNode(children: [], layout, nil) 165 | } 166 | } 167 | 168 | extension LayoutNodeBuilder.FlexBoxViewStep: LayoutNodeConvertible { 169 | public var layoutNode: LayoutNode { 170 | LayoutNode(children: [], layout, view) 171 | } 172 | } 173 | 174 | extension LayoutNodeBuilder.FlexBoxViewBodyStep: LayoutNodeConvertible { 175 | public var layoutNode: LayoutNode { 176 | LayoutNode(children: children, layout, view) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /Sources/Layout/SizeProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreGraphics 3 | 4 | public protocol SizeProvider { 5 | func calculateSize(boundedBy dimensions: LayoutDimensions) -> CGSize 6 | } 7 | 8 | extension Optional: SizeProvider where Wrapped: SizeProvider { 9 | public func calculateSize(boundedBy dimensions: LayoutDimensions) -> CGSize { 10 | switch self { 11 | case .none: 12 | return .zero 13 | case let .some(provider): 14 | return provider.calculateSize(boundedBy: dimensions) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Layout/Spec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | open class LayoutSpec { 5 | public let layoutDirection: UIUserInterfaceLayoutDirection 6 | 7 | public init(layoutDirection: UIUserInterfaceLayoutDirection = .leftToRight) { 8 | self.layoutDirection = layoutDirection 9 | } 10 | 11 | public final func makeLayoutWith(boundingDimensions: LayoutDimensions) -> Layout { 12 | FlatLayoutCalculator().makeLayoutBy( 13 | spec: self, 14 | boundingDimensions: boundingDimensions, 15 | layoutDirection: layoutDirection 16 | ) 17 | } 18 | 19 | open func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 20 | fatalError() 21 | } 22 | } 23 | 24 | open class ModelLayoutSpec: LayoutSpec { 25 | public let model: ModelType 26 | 27 | public init(model: ModelType, layoutDirection: UIUserInterfaceLayoutDirection = .leftToRight) { 28 | self.model = model 29 | 30 | super.init(layoutDirection: layoutDirection) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Layout/ViewFactory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | protocol ViewFactory { 5 | func makeView() -> UIView 6 | 7 | func config(view: UIView, isNew: Bool) 8 | } 9 | 10 | final class GenericViewFactory: ViewFactory { 11 | private let config: (ViewType, Bool) -> Void 12 | 13 | init(_ config: @escaping (ViewType, Bool) -> Void) { 14 | self.config = config 15 | } 16 | 17 | func makeView() -> UIView { 18 | return ViewType(frame: .zero) 19 | } 20 | 21 | func config(view: UIView, isNew: Bool) { 22 | (view as? ViewType).flatMap { config($0, isNew) } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ListKit/CollectionViewAdapter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public final class CollectionViewAdapter: NSObject, UICollectionViewDataSource { 5 | public struct Settings { 6 | public var allowInteractiveMovement: Bool 7 | public var allowMovesInBatchUpdates: Bool 8 | } 9 | 10 | public let collectionView: CollectionViewType 11 | let dataSource = ListViewDataSource() 12 | private let cellId = UUID().uuidString 13 | 14 | public required init(layout: UICollectionViewLayout) { 15 | collectionView = CollectionViewType(frame: .zero, collectionViewLayout: layout) 16 | 17 | if #available(iOS 10.0, *) { 18 | collectionView.isPrefetchingEnabled = false 19 | } 20 | 21 | if #available(iOS 11.0, *) { 22 | collectionView.dragInteractionEnabled = false 23 | } 24 | 25 | settings = Settings( 26 | allowInteractiveMovement: false, 27 | allowMovesInBatchUpdates: true 28 | ) 29 | 30 | super.init() 31 | 32 | collectionView.dataSource = self 33 | 34 | collectionView.register(CellType.self, forCellWithReuseIdentifier: cellId) 35 | } 36 | 37 | deinit { 38 | collectionView.delegate = nil 39 | collectionView.dataSource = nil 40 | } 41 | 42 | // MARK: - Public API 43 | 44 | public var settings: Settings { 45 | willSet { 46 | assert(Thread.isMainThread) 47 | } 48 | } 49 | 50 | public func contextForItem(at index: Int) -> ContextType? { 51 | assert(Thread.isMainThread) 52 | 53 | return dataSource.models[safe: index]?.item.context 54 | } 55 | 56 | public func sizeForItem(at index: Int) -> CGSize? { 57 | assert(Thread.isMainThread) 58 | 59 | return dataSource.models[safe: index]?.layout.size 60 | } 61 | 62 | public func set(boundingDimensions: LayoutDimensions, async: Bool = false, completion: ((Bool) -> Void)? = nil) { 63 | assert(Thread.isMainThread) 64 | 65 | dataSource.set(boundingDimensions: boundingDimensions, async: async) { [weak self] updateType in 66 | self?.update(type: updateType, completion) 67 | } 68 | } 69 | 70 | public func set(items: [ListItem], animated: Bool = true, completion: ((Bool) -> Void)? = nil) { 71 | assert(Thread.isMainThread) 72 | 73 | dataSource.set(newItems: items) { [weak self] updateType in 74 | if animated { 75 | self?.update(type: updateType, completion) 76 | } else { 77 | UIView.performWithoutAnimation { 78 | self?.update(type: updateType, completion) 79 | } 80 | } 81 | } 82 | } 83 | 84 | public func handleMoveGesture(_ g: T) { 85 | guard settings.allowInteractiveMovement else { return } 86 | 87 | switch(g.state) { 88 | case .began: 89 | if let indexPath = collectionView.indexPathForItem(at: g.location(in: collectionView)) { 90 | collectionView.beginInteractiveMovementForItem(at: indexPath) 91 | } 92 | case .changed: 93 | let location = g.location(in: collectionView) 94 | 95 | collectionView.updateInteractiveMovementTargetPosition(location) 96 | case .ended: 97 | collectionView.endInteractiveMovement() 98 | default: 99 | collectionView.cancelInteractiveMovement() 100 | } 101 | } 102 | 103 | private func update(type: UpdateType?, _ completion: ((Bool) -> Void)?) { 104 | switch type { 105 | case .reload: 106 | collectionView.performBatchUpdates({ 107 | collectionView.deleteSections([0]) 108 | collectionView.insertSections([0]) 109 | }, completion: completion) 110 | case let .patch(changes): 111 | let allowMoves: Bool 112 | 113 | if #available(iOS 11.0, *), settings.allowMovesInBatchUpdates { 114 | allowMoves = true 115 | } else { 116 | allowMoves = false 117 | } 118 | 119 | var deletes: [IndexPath] = [] 120 | var inserts: [IndexPath] = [] 121 | var moves: [(IndexPath, IndexPath)] = [] 122 | 123 | changes.forEach { 124 | switch $0 { 125 | case let .delete(index): 126 | deletes.append([0, index]) 127 | case let .insert(index): 128 | inserts.append([0, index]) 129 | case let .update(oldIndex, newIndex): 130 | deletes.append([0, oldIndex]) 131 | inserts.append([0, newIndex]) 132 | case let .move(fromIndex, toIndex): 133 | if allowMoves { 134 | moves.append(([0, fromIndex], [0, toIndex])) 135 | } else { 136 | deletes.append([0, fromIndex]) 137 | inserts.append([0, toIndex]) 138 | } 139 | } 140 | } 141 | 142 | collectionView.performBatchUpdates({ 143 | collectionView.deleteItems(at: deletes) 144 | collectionView.insertItems(at: inserts) 145 | moves.forEach(collectionView.moveItem) 146 | }, completion: completion) 147 | case .none: 148 | completion?(false) 149 | } 150 | } 151 | 152 | // MARK: - UICollectionViewDataSource 153 | 154 | public func numberOfSections(in collectionView: UICollectionView) -> Int { 155 | 1 156 | } 157 | 158 | public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 159 | dataSource.models.count 160 | } 161 | 162 | public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 163 | let cell: CellType = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! CellType 164 | 165 | let index = indexPath.item 166 | 167 | let view: UIView? 168 | 169 | if let model = dataSource.models[safe: index] { 170 | view = model.item.makeView?(model.layout, index) ?? model.layout.makeView() 171 | } else { 172 | view = nil 173 | } 174 | 175 | UIView.performWithoutAnimation { 176 | cell.contentView.subviews.forEach({ $0.removeFromSuperview() }) 177 | view.flatMap(cell.contentView.addSubview(_:)) 178 | } 179 | 180 | return cell 181 | } 182 | 183 | public func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool { 184 | settings.allowInteractiveMovement && dataSource.models[safe: indexPath.item]?.item.canMove ?? false 185 | } 186 | 187 | public func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { 188 | let sourceIndex = sourceIndexPath.item 189 | let destinationIndex = destinationIndexPath.item 190 | 191 | dataSource.moveItem(from: sourceIndex, to: destinationIndex) 192 | 193 | dataSource.models[safe: destinationIndex]?.item.didMove?(sourceIndex, destinationIndex) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /Sources/ListKit/ListItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public final class ListItem: Hashable { 5 | let id: AnyHashable 6 | let layoutSpec: LayoutSpec 7 | 8 | public init(id: IdType, layoutSpec: LayoutSpec) { 9 | self.id = AnyHashable(id) 10 | self.layoutSpec = layoutSpec 11 | } 12 | 13 | // MARK: - Public properties 14 | 15 | public var context: ContextType? 16 | public var makeView: ((Layout, Int) -> UIView)? 17 | public var boundingDimensionsModifier: LayoutDimensions.Modifier? 18 | public var canMove: Bool = false 19 | public var didMove: ((Int, Int) -> Void)? 20 | 21 | // MARK: - Hashable & Equatable 22 | 23 | public func hash(into hasher: inout Hasher) { 24 | hasher.combine(id) 25 | } 26 | 27 | public static func == (lhs: ListItem, rhs: ListItem) -> Bool { 28 | return lhs === rhs ? true : lhs.id == rhs.id 29 | } 30 | } 31 | 32 | extension ListItem { 33 | func makeLayoutWith(_ dimensions: LayoutDimensions) -> Layout { 34 | layoutSpec.makeLayoutWith(boundingDimensions: boundingDimensionsModifier.flatMap(dimensions.modify) ?? dimensions) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/ListKit/ListViewDataSource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | struct CellModel { 5 | let item: ListItem 6 | let layout: Layout 7 | } 8 | 9 | private enum MakeModels { 10 | static func from(boundingDimensions: LayoutDimensions, 11 | items: [ListItem], 12 | async: Bool, 13 | queue: DispatchQueue, 14 | completion: (([CellModel]) -> Void)?) { 15 | if async { 16 | queue.async { 17 | let models = items.map { 18 | CellModel(item: $0, layout: $0.makeLayoutWith(boundingDimensions)) 19 | } 20 | 21 | completion?(models) 22 | } 23 | } else { 24 | let models = queue.sync { 25 | return items.map { 26 | CellModel(item: $0, layout: $0.makeLayoutWith(boundingDimensions)) 27 | } 28 | } 29 | 30 | completion?(models) 31 | } 32 | } 33 | 34 | static func from(boundingDimensions: LayoutDimensions, 35 | newItems: [ListItem], 36 | oldItems: [ListItem], 37 | oldModels: [CellModel], 38 | queue: DispatchQueue, 39 | completion: (([CellModel], Diff.Changes) -> Void)?) { 40 | queue.async { 41 | let changes = Diff.between(oldItems, and: newItems) 42 | 43 | if changes.isEmpty { 44 | completion?([], []) 45 | 46 | return 47 | } 48 | 49 | var layouts = oldModels.reduce(into: [ListItem: Layout]()) { 50 | $0[$1.item] = $1.layout 51 | } 52 | 53 | changes.forEach { 54 | switch $0 { 55 | case let .delete(index): 56 | layouts[oldItems[index]] = nil 57 | case let .update(index, _): 58 | layouts[oldItems[index]] = nil 59 | case .insert, .move: 60 | break 61 | } 62 | } 63 | 64 | let newModels = newItems.map { 65 | CellModel(item: $0, layout: layouts[$0] ?? $0.makeLayoutWith(boundingDimensions)) 66 | } 67 | 68 | completion?(newModels, changes) 69 | } 70 | } 71 | } 72 | 73 | enum UpdateType { 74 | case reload 75 | case patch(Diff.Changes) 76 | } 77 | 78 | final class ListViewDataSource { 79 | private let queue = DispatchQueue(label: "ALLKit.ListViewDataSource.queue") 80 | 81 | private var generation: UInt64 = 0 82 | private var boundingDimensions: LayoutDimensions? 83 | private var items: [ListItem] = [] 84 | 85 | private(set) var models: [CellModel] = [] 86 | 87 | // MARK: - 88 | 89 | func set(boundingDimensions: LayoutDimensions, async: Bool, completion: ((UpdateType?) -> Void)?) { 90 | guard boundingDimensions != self.boundingDimensions else { 91 | completion?(nil) 92 | 93 | return 94 | } 95 | 96 | self.boundingDimensions = boundingDimensions 97 | 98 | generation += 1 99 | let g = generation 100 | 101 | MakeModels.from(boundingDimensions: boundingDimensions, items: items, async: async, queue: queue) { [weak self] models in 102 | onMainThread { 103 | guard let self = self else { return } 104 | 105 | guard self.generation == g else { 106 | completion?(nil) 107 | 108 | return 109 | } 110 | 111 | self.models = models 112 | 113 | completion?(.reload) 114 | } 115 | } 116 | } 117 | 118 | func set(newItems: [ListItem], completion: ((UpdateType?) -> Void)?) { 119 | let oldItems: [ListItem] 120 | 121 | (oldItems, items) = (items, newItems) 122 | 123 | guard let boundingDimensions = boundingDimensions else { 124 | completion?(nil) 125 | 126 | return 127 | } 128 | 129 | generation += 1 130 | let g = generation 131 | 132 | MakeModels.from(boundingDimensions: boundingDimensions, newItems: newItems, oldItems: oldItems, oldModels: models, queue: queue) { [weak self] (models, changes) in 133 | onMainThread { 134 | guard let self = self else { return } 135 | 136 | guard self.generation == g, !changes.isEmpty else { 137 | completion?(nil) 138 | 139 | return 140 | } 141 | 142 | let wasEmpty = self.models.isEmpty 143 | 144 | self.models = models 145 | 146 | if wasEmpty || models.isEmpty { 147 | completion?(.reload) 148 | } else { 149 | completion?(.patch(changes)) 150 | } 151 | } 152 | } 153 | } 154 | 155 | func moveItem(from i: Int, to j: Int) { 156 | guard items.count == models.count else { 157 | return 158 | } 159 | 160 | models.move(from: i, to: j) 161 | items.move(from: i, to: j) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Sources/ListKit/Utils.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Array { 4 | mutating func move(from i: Index, to j: Index) { 5 | insert(remove(at: i), at: j) 6 | } 7 | 8 | subscript(safe index: Index) -> Element? { 9 | indices.contains(index) ? self[index] : nil 10 | } 11 | } 12 | 13 | func onMainThread(_ closure: @escaping () -> Void) { 14 | if Thread.isMainThread { 15 | closure() 16 | } else { 17 | DispatchQueue.main.async(execute: closure) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/StringBuilder/AttributedStringBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | extension String { 5 | public func attributed() -> AttributedStringBuilder { 6 | return AttributedStringBuilder(self) 7 | } 8 | } 9 | 10 | public final class AttributedStringBuilder { 11 | private let string: String 12 | 13 | public init(_ string: String) { 14 | self.string = string 15 | } 16 | 17 | // MARK: - 18 | 19 | private var attributes = [NSAttributedString.Key: Any]() 20 | 21 | private var paragraphStyle: NSMutableParagraphStyle { 22 | if let style = attributes[.paragraphStyle] as? NSMutableParagraphStyle { 23 | return style 24 | } 25 | 26 | let style = NSMutableParagraphStyle() 27 | 28 | attributes[.paragraphStyle] = style 29 | 30 | return style 31 | } 32 | 33 | // MARK: - 34 | 35 | public func alignment(_ value: NSTextAlignment) -> Self { 36 | paragraphStyle.alignment = value 37 | 38 | return self 39 | } 40 | 41 | public func allowsDefaultTighteningForTruncation(_ value: Bool) -> Self { 42 | paragraphStyle.allowsDefaultTighteningForTruncation = value 43 | 44 | return self 45 | } 46 | 47 | public func backgroundColor(_ value: UIColor) -> Self { 48 | attributes[.backgroundColor] = value 49 | 50 | return self 51 | } 52 | 53 | public func baseWritingDirection(_ value: NSWritingDirection) -> Self { 54 | paragraphStyle.baseWritingDirection = value 55 | 56 | return self 57 | } 58 | 59 | public func baselineOffset(_ value: Float) -> Self { 60 | attributes[.baselineOffset] = NSNumber(value: value) 61 | 62 | return self 63 | } 64 | 65 | public func defaultTabInterval(_ value: CGFloat) -> Self { 66 | paragraphStyle.defaultTabInterval = value 67 | 68 | return self 69 | } 70 | 71 | public func expansion(_ value: Float) -> Self { 72 | attributes[.expansion] = NSNumber(value: value) 73 | 74 | return self 75 | } 76 | 77 | public func firstLineHeadIndent(_ value: CGFloat) -> Self { 78 | paragraphStyle.firstLineHeadIndent = value 79 | 80 | return self 81 | } 82 | 83 | public func font(_ value: UIFont) -> Self { 84 | attributes[.font] = value 85 | 86 | return self 87 | } 88 | 89 | public func foregroundColor(_ value: UIColor) -> Self { 90 | attributes[.foregroundColor] = value 91 | 92 | return self 93 | } 94 | 95 | public func headIndent(_ value: CGFloat) -> Self { 96 | paragraphStyle.headIndent = value 97 | 98 | return self 99 | } 100 | 101 | public func hyphenationFactor(_ value: Float) -> Self { 102 | paragraphStyle.hyphenationFactor = value 103 | 104 | return self 105 | } 106 | 107 | public func kern(_ value: Float) -> Self { 108 | attributes[.kern] = NSNumber(value: value) 109 | 110 | return self 111 | } 112 | 113 | public func ligature(_ value: Int) -> Self { 114 | attributes[.ligature] = NSNumber(value: value) 115 | 116 | return self 117 | } 118 | 119 | public func lineBreakMode(_ value: NSLineBreakMode) -> Self { 120 | paragraphStyle.lineBreakMode = value 121 | 122 | return self 123 | } 124 | 125 | public func lineHeightMultiple(_ value: CGFloat) -> Self { 126 | paragraphStyle.lineHeightMultiple = value 127 | 128 | return self 129 | } 130 | 131 | public func lineSpacing(_ value: CGFloat) -> Self { 132 | paragraphStyle.lineSpacing = value 133 | 134 | return self 135 | } 136 | 137 | public func maximumLineHeight(_ value: CGFloat) -> Self { 138 | paragraphStyle.maximumLineHeight = value 139 | 140 | return self 141 | } 142 | 143 | public func minimumLineHeight(_ value: CGFloat) -> Self { 144 | paragraphStyle.minimumLineHeight = value 145 | 146 | return self 147 | } 148 | 149 | public func obliqueness(_ value: Float) -> Self { 150 | attributes[.obliqueness] = NSNumber(value: value) 151 | 152 | return self 153 | } 154 | 155 | public func paragraphSpacing(_ value: CGFloat) -> Self { 156 | paragraphStyle.paragraphSpacing = value 157 | 158 | return self 159 | } 160 | 161 | public func paragraphSpacingBefore(_ value: CGFloat) -> Self { 162 | paragraphStyle.paragraphSpacingBefore = value 163 | 164 | return self 165 | } 166 | 167 | public func shadow(offsetX: CGFloat, 168 | offsetY: CGFloat, 169 | blurRadius: CGFloat, 170 | color: UIColor?) -> Self { 171 | let value = NSShadow() 172 | value.shadowOffset = CGSize(width: offsetX, height: offsetY) 173 | value.shadowBlurRadius = blurRadius 174 | value.shadowColor = color 175 | 176 | attributes[.shadow] = value 177 | 178 | return self 179 | } 180 | 181 | public func strikethroughColor(_ value: UIColor) -> Self { 182 | attributes[.strikethroughColor] = value 183 | 184 | return self 185 | } 186 | 187 | public func strikethroughStyle(_ value: Int) -> Self { 188 | attributes[.strikethroughStyle] = NSNumber(value: value) 189 | 190 | return self 191 | } 192 | 193 | public func strokeColor(_ value: UIColor) -> Self { 194 | attributes[.strokeColor] = value 195 | 196 | return self 197 | } 198 | 199 | public func strokeWidth(_ value: Float) -> Self { 200 | attributes[.strokeWidth] = NSNumber(value: value) 201 | 202 | return self 203 | } 204 | 205 | public func tailIndent(_ value: CGFloat) -> Self { 206 | paragraphStyle.tailIndent = value 207 | 208 | return self 209 | } 210 | 211 | public func underlineColor(_ value: UIColor) -> Self { 212 | attributes[.underlineColor] = value 213 | 214 | return self 215 | } 216 | 217 | public func underlineStyle(_ value: NSUnderlineStyle) -> Self { 218 | attributes[.underlineStyle] = NSNumber(value: value.rawValue) 219 | 220 | return self 221 | } 222 | 223 | // MARK: - 224 | 225 | public func make() -> NSAttributedString { 226 | return NSAttributedString( 227 | string: string, 228 | attributes: attributes 229 | ) 230 | } 231 | 232 | public func makeMutable() -> NSMutableAttributedString { 233 | return NSMutableAttributedString( 234 | string: string, 235 | attributes: attributes 236 | ) 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /Sources/Yoga/Yoga.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreGraphics 3 | import yoga 4 | 5 | // https://yogalayout.com/docs 6 | 7 | public enum Yoga { 8 | public typealias MeasureFunc = (CGSize) -> CGSize 9 | 10 | public final class Config { 11 | fileprivate private(set) var ref: YGConfigRef! 12 | 13 | public init(pointScale: CGFloat) { 14 | ref = YGConfigNew() 15 | YGConfigSetPointScaleFactor(ref, Float(pointScale)) 16 | } 17 | 18 | deinit { 19 | YGConfigFree(ref) 20 | } 21 | } 22 | 23 | public final class Node { 24 | public let measureFunc: MeasureFunc? 25 | 26 | fileprivate private(set) var ref: YGNodeRef! 27 | 28 | public init(config: Config, measureFunc: MeasureFunc? = nil) { 29 | self.measureFunc = measureFunc 30 | 31 | ref = YGNodeNewWithConfig(config.ref) 32 | 33 | guard self.measureFunc != nil else { 34 | return 35 | } 36 | 37 | YGNodeSetContext(ref, Unmanaged.passUnretained(self).toOpaque()) 38 | 39 | YGNodeSetMeasureFunc(ref) { (ref, width, widthMode, height, heightMode) -> YGSize in 40 | let node = Unmanaged.fromOpaque(YGNodeGetContext(ref)).takeUnretainedValue() 41 | let size = node.measureFunc?(CGSize(width: CGFloat(width), height: CGFloat(height))) ?? .zero 42 | return YGSize(width: Float(size.width), height: Float(size.height)) 43 | } 44 | } 45 | 46 | deinit { 47 | YGNodeFree(ref) 48 | } 49 | 50 | // MARK: - 51 | 52 | public func calculateLayout(width: Float, height: Float, parentDirection: YGDirection) { 53 | YGNodeCalculateLayout(ref, width, height, parentDirection) 54 | } 55 | 56 | // MARK: - 57 | 58 | public private(set) var children: [Node] = [] 59 | 60 | @discardableResult 61 | public func add(child: Node) -> Self { 62 | children.append(child) 63 | 64 | YGNodeInsertChild(ref, child.ref, YGNodeGetChildCount(ref)) 65 | 66 | return self 67 | } 68 | 69 | // MARK: - 70 | 71 | public var frame: CGRect { 72 | CGRect( 73 | x: CGFloat(YGNodeLayoutGetLeft(ref)), 74 | y: CGFloat(YGNodeLayoutGetTop(ref)), 75 | width: CGFloat(YGNodeLayoutGetWidth(ref)), 76 | height: CGFloat(YGNodeLayoutGetHeight(ref)) 77 | ) 78 | } 79 | 80 | public var flexDirection: YGFlexDirection { 81 | get { YGNodeStyleGetFlexDirection(ref) } 82 | set { YGNodeStyleSetFlexDirection(ref, newValue) } 83 | } 84 | 85 | public var flex: Float { 86 | get { YGNodeStyleGetFlex(ref) } 87 | set { YGNodeStyleSetFlex(ref, newValue) } 88 | } 89 | 90 | public var flexWrap: YGWrap { 91 | get { YGNodeStyleGetFlexWrap(ref) } 92 | set { YGNodeStyleSetFlexWrap(ref, newValue) } 93 | } 94 | 95 | public var flexGrow: Float { 96 | get { YGNodeStyleGetFlexGrow(ref) } 97 | set { YGNodeStyleSetFlexGrow(ref, newValue) } 98 | } 99 | 100 | public var flexShrink: Float { 101 | get { YGNodeStyleGetFlexShrink(ref) } 102 | set { YGNodeStyleSetFlexShrink(ref, newValue) } 103 | } 104 | 105 | public var flexBasis: YGValue { 106 | get { YGNodeStyleGetFlexBasis(ref) } 107 | set { set(ref, newValue, YGNodeStyleSetFlexBasis, YGNodeStyleSetFlexBasisPercent) } 108 | } 109 | 110 | public var display: YGDisplay { 111 | get { YGNodeStyleGetDisplay(ref) } 112 | set { YGNodeStyleSetDisplay(ref, newValue) } 113 | } 114 | 115 | public var aspectRatio: Float { 116 | get { YGNodeStyleGetAspectRatio(ref) } 117 | set { YGNodeStyleSetAspectRatio(ref, newValue) } 118 | } 119 | 120 | public var justifyContent: YGJustify { 121 | get { YGNodeStyleGetJustifyContent(ref) } 122 | set { YGNodeStyleSetJustifyContent(ref, newValue) } 123 | } 124 | 125 | public var alignContent: YGAlign { 126 | get { YGNodeStyleGetAlignContent(ref) } 127 | set { YGNodeStyleSetAlignContent(ref, newValue) } 128 | } 129 | 130 | public var alignItems: YGAlign { 131 | get { YGNodeStyleGetAlignItems(ref) } 132 | set { YGNodeStyleSetAlignItems(ref, newValue) } 133 | } 134 | 135 | public var alignSelf: YGAlign { 136 | get { YGNodeStyleGetAlignSelf(ref) } 137 | set { YGNodeStyleSetAlignSelf(ref, newValue) } 138 | } 139 | 140 | public var positionType: YGPositionType { 141 | get { YGNodeStyleGetPositionType(ref) } 142 | set { YGNodeStyleSetPositionType(ref, newValue) } 143 | } 144 | 145 | public var width: YGValue { 146 | get { YGNodeStyleGetWidth(ref) } 147 | set { set(ref, newValue, YGNodeStyleSetWidth, YGNodeStyleSetWidthPercent) } 148 | } 149 | 150 | public var height: YGValue { 151 | get { YGNodeStyleGetHeight(ref) } 152 | set { set(ref, newValue, YGNodeStyleSetHeight, YGNodeStyleSetHeightPercent) } 153 | } 154 | 155 | public var minWidth: YGValue { 156 | get { YGNodeStyleGetMinWidth(ref) } 157 | set { set(ref, newValue, YGNodeStyleSetMinWidth, YGNodeStyleSetMinWidthPercent) } 158 | } 159 | 160 | public var minHeight: YGValue { 161 | get { YGNodeStyleGetMinHeight(ref) } 162 | set { set(ref, newValue, YGNodeStyleSetMinHeight, YGNodeStyleSetMinHeightPercent) } 163 | } 164 | 165 | public var maxWidth: YGValue { 166 | get { YGNodeStyleGetMaxWidth(ref) } 167 | set { set(ref, newValue, YGNodeStyleSetMaxWidth, YGNodeStyleSetMaxWidthPercent) } 168 | } 169 | 170 | public var maxHeight: YGValue { 171 | get { YGNodeStyleGetMaxHeight(ref) } 172 | set { set(ref, newValue, YGNodeStyleSetMaxHeight, YGNodeStyleSetMaxHeightPercent) } 173 | } 174 | 175 | public var top: YGValue { 176 | get { YGNodeStyleGetPosition(ref, .top) } 177 | set { set(ref, newValue, .top, YGNodeStyleSetPosition, YGNodeStyleSetPositionPercent) } 178 | } 179 | 180 | public var right: YGValue { 181 | get { YGNodeStyleGetPosition(ref, .right) } 182 | set { set(ref, newValue, .right, YGNodeStyleSetPosition, YGNodeStyleSetPositionPercent) } 183 | } 184 | 185 | public var bottom: YGValue { 186 | get { YGNodeStyleGetPosition(ref, .bottom) } 187 | set { set(ref, newValue, .bottom, YGNodeStyleSetPosition, YGNodeStyleSetPositionPercent) } 188 | } 189 | 190 | public var left: YGValue { 191 | get { YGNodeStyleGetPosition(ref, .left) } 192 | set { set(ref, newValue, .left, YGNodeStyleSetPosition, YGNodeStyleSetPositionPercent) } 193 | } 194 | 195 | public var marginTop: YGValue { 196 | get { YGNodeStyleGetMargin(ref, .top) } 197 | set { set(ref, newValue, .top, YGNodeStyleSetMargin, YGNodeStyleSetMarginPercent) } 198 | } 199 | 200 | public var marginRight: YGValue { 201 | get { YGNodeStyleGetMargin(ref, .right) } 202 | set { set(ref, newValue, .right, YGNodeStyleSetMargin, YGNodeStyleSetMarginPercent) } 203 | } 204 | 205 | public var marginBottom: YGValue { 206 | get { YGNodeStyleGetMargin(ref, .bottom) } 207 | set { set(ref, newValue, .bottom, YGNodeStyleSetMargin, YGNodeStyleSetMarginPercent) } 208 | } 209 | 210 | public var marginLeft: YGValue { 211 | get { YGNodeStyleGetMargin(ref, .left) } 212 | set { set(ref, newValue, .left, YGNodeStyleSetMargin, YGNodeStyleSetMarginPercent) } 213 | } 214 | 215 | public var paddingTop: YGValue { 216 | get { YGNodeStyleGetPadding(ref, .top) } 217 | set { set(ref, newValue, .top, YGNodeStyleSetPadding, YGNodeStyleSetPaddingPercent) } 218 | } 219 | 220 | public var paddingRight: YGValue { 221 | get { YGNodeStyleGetPadding(ref, .right) } 222 | set { set(ref, newValue, .right, YGNodeStyleSetPadding, YGNodeStyleSetPaddingPercent) } 223 | } 224 | 225 | public var paddingBottom: YGValue { 226 | get { YGNodeStyleGetPadding(ref, .bottom) } 227 | set { set(ref, newValue, .bottom, YGNodeStyleSetPadding, YGNodeStyleSetPaddingPercent) } 228 | } 229 | 230 | public var paddingLeft: YGValue { 231 | get { YGNodeStyleGetPadding(ref, .left) } 232 | set { set(ref, newValue, .left, YGNodeStyleSetPadding, YGNodeStyleSetPaddingPercent) } 233 | } 234 | 235 | // MARK: - 236 | 237 | private func set(_ ref: YGNodeRef, 238 | _ value: YGValue, 239 | _ pointSetter: (YGNodeRef?, Float) -> Void, 240 | _ percentSetter: (YGNodeRef?, Float) -> Void) { 241 | switch value.unit { 242 | case .point: 243 | pointSetter(ref, value.value) 244 | case .percent: 245 | percentSetter(ref, value.value) 246 | @unknown default: 247 | assertionFailure() 248 | } 249 | } 250 | 251 | private func set(_ ref: YGNodeRef, 252 | _ value: YGValue, 253 | _ edge: YGEdge, 254 | _ pointSetter: (YGNodeRef?, YGEdge, Float) -> Void, 255 | _ percentSetter: (YGNodeRef?, YGEdge, Float) -> Void) { 256 | switch value.unit { 257 | case .point: 258 | pointSetter(ref, edge, value.value) 259 | case .percent: 260 | percentSetter(ref, edge, value.value) 261 | @unknown default: 262 | assertionFailure() 263 | } 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /Tests/AsyncLabel+Tests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Foundation 3 | import UIKit 4 | import ALLKit 5 | 6 | class AsyncLabelTests: XCTestCase { 7 | func testStability() { 8 | let label = AsyncLabel(frame: CGRect(x: 0, y: 0, width: 400, height: 300)) 9 | 10 | // 20 ~ mean number of labels on screen 11 | 12 | (0..<20).forEach { i in 13 | label.stringDrawing = i % 2 == 0 ? nil : UUID().uuidString.attributed() 14 | .font(UIFont.boldSystemFont(ofSize: 40)) 15 | .foregroundColor(UIColor.black) 16 | .make() 17 | .drawing() 18 | } 19 | 20 | let exp = XCTestExpectation() 21 | 22 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 23 | exp.fulfill() 24 | } 25 | 26 | wait(for: [exp], timeout: 1) 27 | 28 | XCTAssert(label.layer.contents != nil) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/BoundingSize+Tests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import ALLKit 3 | 4 | class LayoutDimensionsTests: XCTestCase { 5 | func test1() { 6 | let x = CGSize(width: 100, height: CGFloat.nan).layoutDimensions 7 | let y = CGSize(width: 100, height: CGFloat.nan).layoutDimensions 8 | 9 | XCTAssert(x == y) 10 | } 11 | 12 | func test2() { 13 | let x = CGSize(width: CGFloat.nan, height: 100).layoutDimensions 14 | let y = CGSize(width: CGFloat.nan, height: 100).layoutDimensions 15 | 16 | XCTAssert(x == y) 17 | } 18 | 19 | func test3() { 20 | let x = CGSize(width: 100, height: 100).layoutDimensions 21 | let y = CGSize(width: 100, height: 100).layoutDimensions 22 | 23 | XCTAssert(x == y) 24 | } 25 | 26 | func test4() { 27 | let x = LayoutDimension(value: nil) 28 | let y = LayoutDimension(value: nil) 29 | 30 | XCTAssert(x == y) 31 | } 32 | 33 | func test5() { 34 | let x = LayoutDimension(value: 100) 35 | let y = LayoutDimension(value: 100) 36 | 37 | XCTAssert(x == y) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/CollectionViewAdapter+Tests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Foundation 3 | import UIKit 4 | 5 | @testable 6 | import ALLKit 7 | 8 | private class TestLayoutSpec: LayoutSpec { 9 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 10 | return LayoutNode({ 11 | $0.height(100) 12 | }) 13 | } 14 | } 15 | 16 | private class CustomCollectionView: UICollectionView {} 17 | private class CustomCell: UICollectionViewCell {} 18 | 19 | class CollectionViewAdapterTests: XCTestCase { 20 | func testStability() { 21 | let adapter = CollectionViewAdapter(layout: UICollectionViewFlowLayout()) 22 | 23 | let n = 500 24 | 25 | let exp = XCTestExpectation() 26 | 27 | (0.. ListItem in 35 | ListItem( 36 | id: UUID().uuidString, 37 | layoutSpec: TestLayoutSpec() 38 | ) 39 | }) 40 | 41 | adapter.set(items: items, animated: i % 2 == 0) 42 | } 43 | } 44 | 45 | adapter.set(boundingDimensions: CGSize(width: 500, height: 500).layoutDimensions) { _ in 46 | exp.fulfill() 47 | } 48 | 49 | wait(for: [exp], timeout: 100) 50 | } 51 | 52 | func testCustomCellsAndCollectionView() { 53 | let adapter = CollectionViewAdapter(layout: UICollectionViewFlowLayout()) 54 | adapter.collectionView.frame = CGRect(x: 0, y: 0, width: 300, height: 500) 55 | 56 | var items: [ListItem] = [] 57 | 58 | do { 59 | let id = UUID().uuidString 60 | let li = ListItem(id: id, layoutSpec: TestLayoutSpec()) 61 | 62 | items.append(li) 63 | } 64 | 65 | adapter.set(items: items) 66 | 67 | let exp = XCTestExpectation() 68 | 69 | adapter.set(boundingDimensions: CGSize(width: 300, height: CGFloat.nan).layoutDimensions) { _ in 70 | exp.fulfill() 71 | } 72 | 73 | wait(for: [exp], timeout: 10) 74 | 75 | XCTAssert(adapter.collectionView.cellForItem(at: IndexPath(item: 0, section: 0)) is CustomCell) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Tests/Diff+Tests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import ALLKit 3 | 4 | private struct Model: Hashable { 5 | let id: String 6 | let value: String 7 | } 8 | 9 | private struct DiffableModel: Diffable { 10 | var diffId: String 11 | var value: String 12 | } 13 | 14 | class DiffTests: XCTestCase { 15 | func testAllInserts() { 16 | let result = Diff.between(Array(""), and: Array("abc")) 17 | 18 | var inserts: [Int] = [] 19 | 20 | result.forEach { 21 | switch $0 { 22 | case .delete, .move, .update: 23 | XCTAssert(false) 24 | case let .insert(index): 25 | inserts.append(index) 26 | } 27 | } 28 | 29 | XCTAssert(inserts == [0,1,2]) 30 | } 31 | 32 | func testAllDeletes() { 33 | let result = Diff.between(Array("abc"), and: Array("")) 34 | 35 | var deletes: [Int] = [] 36 | 37 | result.forEach { 38 | switch $0 { 39 | case .insert, .move, .update: 40 | XCTAssert(false) 41 | case let .delete(index): 42 | deletes.append(index) 43 | } 44 | } 45 | 46 | XCTAssert(deletes == [0,1,2]) 47 | } 48 | 49 | func testNoChanges1() { 50 | let result = Diff.between(Array(""), and: Array("")) 51 | 52 | XCTAssert(result.isEmpty) 53 | } 54 | 55 | func testNoChanges2() { 56 | let result = Diff.between(Array("abc"), and: Array("abc")) 57 | 58 | XCTAssert(result.isEmpty) 59 | } 60 | 61 | func testSimpleMove() { 62 | let result = Diff.between(Array("abc"), and: Array("acb")) 63 | 64 | var moves: [(Int, Int)] = [] 65 | 66 | result.forEach { 67 | switch $0 { 68 | case .delete, .insert, .update: 69 | XCTAssert(false) 70 | case let .move(from, to): 71 | moves.append((from, to)) 72 | } 73 | } 74 | 75 | XCTAssert(moves.count == 2) 76 | XCTAssert(moves[0].0 == 2 && moves[0].1 == 1) 77 | XCTAssert(moves[1].0 == 1 && moves[1].1 == 2) 78 | } 79 | 80 | func testNew() { 81 | let result = Diff.between(Array("abc"), and: Array("xyz")) 82 | 83 | var deletes = 0 84 | var inserts = 0 85 | 86 | result.forEach { 87 | switch $0 { 88 | case .update, .move: 89 | XCTAssert(false) 90 | case .delete: 91 | deletes += 1 92 | case .insert: 93 | inserts += 1 94 | } 95 | } 96 | 97 | XCTAssert(deletes == 3) 98 | XCTAssert(inserts == 3) 99 | } 100 | 101 | func testDiffable() { 102 | let m1 = DiffableModel(diffId: "1", value: "x") 103 | var m2 = DiffableModel(diffId: "2", value: "y") 104 | 105 | let x = [m1, m2] 106 | 107 | m2.value = "z" 108 | 109 | let y = [m1, m2] 110 | 111 | let d = Diff.between(x, and: y) 112 | 113 | XCTAssert(d.count == 1) 114 | XCTAssert(d[0] == .update(1, 1)) 115 | } 116 | 117 | func testPerfomance() { 118 | let a1 = (0..<1000).map { _ in Int.random(in: 1..<1000) } 119 | let a2 = (0..<1000).map { _ in Int.random(in: 1..<1000) } 120 | 121 | measure { 122 | _ = Diff.between(a1, and: a2) 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Tests/Layout+Tests.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import XCTest 3 | 4 | @testable 5 | import ALLKit 6 | 7 | private class LayoutSpec1: ModelLayoutSpec { 8 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 9 | let text = model.attributed() 10 | .font(UIFont.systemFont(ofSize: 14)) 11 | .foregroundColor(UIColor.black) 12 | .make() 13 | 14 | return LayoutNode(sizeProvider: text) { (label: UILabel, _) in 15 | label.numberOfLines = 0 16 | label.attributedText = text 17 | } 18 | } 19 | } 20 | 21 | private class LayoutSpec2: LayoutSpec { 22 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 23 | let node1 = LayoutNode({ 24 | $0.width(100).height(100) 25 | }) { (view: UIView, _) in } 26 | 27 | let node2 = LayoutNode({ 28 | $0.width(110).height(110).margin(.left(10)) 29 | }) { (view: UIView, _) in } 30 | 31 | return LayoutNode(children: [node1, node2], { 32 | $0.padding(.all(10)).flexDirection(.row).alignItems(.center) 33 | }) 34 | } 35 | } 36 | 37 | private class LayoutSpec3: LayoutSpec { 38 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 39 | let viewNodes = (0..<100).map { _ in 40 | LayoutNode({ 41 | $0.width(100).height(100).margin(.all(5)) 42 | }) { (view: UIView, _) in } 43 | } 44 | 45 | let contentNode = LayoutNode(children: viewNodes, { 46 | $0.flexDirection(.row).padding(.all(5)) 47 | }) 48 | 49 | return LayoutNode(children: [contentNode]) 50 | } 51 | } 52 | 53 | private class LayoutSpec5: ModelLayoutSpec { 54 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 55 | return LayoutNode(sizeProvider: model) { (label: UILabel, _) in 56 | label.numberOfLines = 0 57 | label.attributedText = self.model 58 | } 59 | } 60 | } 61 | 62 | private class LayoutSpec6: ModelLayoutSpec<(() -> Void, () -> Void)> { 63 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 64 | return LayoutNode(children: [], { 65 | $0.width(40).height(40) 66 | }) { (view: UIView, isNew) in 67 | if isNew { 68 | self.model.0() 69 | } else { 70 | self.model.1() 71 | } 72 | } 73 | } 74 | } 75 | 76 | class LayoutTests: XCTestCase { 77 | func testConfigView() { 78 | let view = UIView() 79 | 80 | let ls1 = LayoutSpec1(model: "abc") 81 | let ls2 = LayoutSpec1(model: "xyz") 82 | 83 | ls1.makeLayoutWith(boundingDimensions: CGSize(width: 100, height: CGFloat.nan).layoutDimensions).setup(in: view) 84 | 85 | let lbl1 = view.subviews.first as! UILabel 86 | 87 | XCTAssert(lbl1.attributedText?.string == "abc") 88 | 89 | ls2.makeLayoutWith(boundingDimensions: CGSize(width: 100, height: CGFloat.nan).layoutDimensions).setup(in: view) 90 | 91 | let lbl2 = view.subviews.first as! UILabel 92 | 93 | XCTAssert(lbl1 === lbl2) 94 | 95 | XCTAssert(lbl2.attributedText?.string == "xyz") 96 | } 97 | 98 | func testFramesAndOrigins() { 99 | let view = UIView() 100 | 101 | LayoutSpec2().makeLayoutWith(boundingDimensions: CGSize(width: .nan, height: CGFloat.nan).layoutDimensions).setup(in: view) 102 | 103 | XCTAssert(view.frame.size == CGSize(width: 240, height: 130)) 104 | XCTAssert(view.subviews[0].frame == CGRect(x: 10, y: 15, width: 100, height: 100)) 105 | XCTAssert(view.subviews[1].frame == CGRect(x: 120, y: 10, width: 110, height: 110)) 106 | } 107 | 108 | func testNilText() { 109 | do { 110 | let view = UIView() 111 | 112 | LayoutSpec5(model: nil).makeLayoutWith(boundingDimensions: CGSize(width: .nan, height: CGFloat.nan).layoutDimensions).setup(in: view) 113 | 114 | let firstChild = view.subviews[0] 115 | 116 | XCTAssert(firstChild.frame.width == 0 && firstChild.frame.height == 0) 117 | } 118 | 119 | do { 120 | let view = UIView() 121 | 122 | let text = "qwe".attributed().font(UIFont.boldSystemFont(ofSize: 40)).make() 123 | 124 | LayoutSpec5(model: text).makeLayoutWith(boundingDimensions: CGSize(width: .nan, height: CGFloat.nan).layoutDimensions).setup(in: view) 125 | 126 | let firstChild = view.subviews[0] 127 | 128 | XCTAssert(firstChild.frame.width > 0 && firstChild.frame.height > 0) 129 | } 130 | } 131 | 132 | func testReuseView() { 133 | var newCount = 0 134 | var reuseCount = 0 135 | 136 | let layoutSpec = LayoutSpec6(model: ({ 137 | newCount += 1 138 | }, { 139 | reuseCount += 1 140 | })) 141 | 142 | let layout = layoutSpec.makeLayoutWith(boundingDimensions: CGSize(width: .nan, height: CGFloat.nan).layoutDimensions) 143 | 144 | let view = layout.makeView() 145 | 146 | layout.makeContentIn(view: view) 147 | 148 | XCTAssert(newCount == 1) 149 | XCTAssert(reuseCount == 1) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Tests/ListItem+Tests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Foundation 3 | import UIKit 4 | 5 | @testable 6 | import ALLKit 7 | 8 | private class TestLayoutSpec: LayoutSpec { 9 | override func makeNodeWith(boundingDimensions: LayoutDimensions) -> LayoutNodeConvertible { 10 | return LayoutNode() 11 | } 12 | } 13 | 14 | private struct TestModel: Equatable { 15 | let id: String 16 | let value: Int 17 | } 18 | 19 | class ListItemTests: XCTestCase { 20 | func testEquality() { 21 | let m1 = TestModel(id: "1", value: 100) 22 | let m2 = TestModel(id: "2", value: 200) 23 | let m3 = TestModel(id: "1", value: 100) 24 | 25 | let item1 = ListItem(id: m1.id, layoutSpec: TestLayoutSpec()) 26 | let item2 = ListItem(id: m2.id, layoutSpec: TestLayoutSpec()) 27 | let item3 = ListItem(id: m3.id, layoutSpec: TestLayoutSpec()) 28 | 29 | XCTAssert(item1 == item3) 30 | XCTAssert(item3 == item1) 31 | XCTAssert(item1 != item2) 32 | XCTAssert(item3 != item2) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/TextBuilder+Tests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Foundation 3 | import UIKit 4 | import ALLKit 5 | 6 | class TextBuilderTests: XCTestCase { 7 | func testStability() { 8 | let loremIpsum = """ 9 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 10 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 11 | 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. 12 | """ 13 | 14 | let text = loremIpsum.attributed() 15 | .alignment(.center) 16 | .allowsDefaultTighteningForTruncation(true) 17 | .backgroundColor(UIColor.black) 18 | .baselineOffset(1) 19 | .baseWritingDirection(.leftToRight) 20 | .defaultTabInterval(10) 21 | .expansion(1) 22 | .firstLineHeadIndent(1) 23 | .font(UIFont.boldSystemFont(ofSize: 40)) 24 | .foregroundColor(UIColor.white) 25 | .headIndent(10) 26 | .hyphenationFactor(1) 27 | .kern(3) 28 | .ligature(1) 29 | .lineBreakMode(.byWordWrapping) 30 | .lineHeightMultiple(2) 31 | .lineSpacing(10) 32 | .maximumLineHeight(10000) 33 | .minimumLineHeight(30) 34 | .obliqueness(3) 35 | .paragraphSpacing(100) 36 | .paragraphSpacingBefore(100) 37 | .shadow(offsetX: 5, offsetY: 5, blurRadius: 50, color: UIColor.white) 38 | .strikethroughColor(UIColor.black) 39 | .strikethroughStyle(1) 40 | .strokeColor(UIColor.red) 41 | .strokeWidth(1) 42 | .tailIndent(100) 43 | .underlineColor(UIColor.green) 44 | .underlineStyle(.patternDashDotDot) 45 | 46 | let mutableString = text.makeMutable() 47 | 48 | mutableString.append(text.make()) 49 | 50 | let label = UILabel() 51 | label.attributedText = mutableString 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Tests/UIHelpers+Tests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import ALLKit 3 | 4 | class UIHelpersTests: XCTestCase { 5 | func testSimpleEvent() { 6 | var result = false 7 | 8 | let btn = UIButton() 9 | 10 | btn.all_setEventHandler(for: .touchUpInside) { result = true } 11 | btn.sendActions(for: .touchUpInside) 12 | 13 | XCTAssert(result) 14 | } 15 | 16 | func testOverrideEvent() { 17 | var result = "" 18 | 19 | let btn = UIButton() 20 | 21 | btn.all_setEventHandler(for: .touchUpInside) { result.append("a") } 22 | btn.sendActions(for: .touchUpInside) 23 | 24 | btn.all_setEventHandler(for: .touchUpInside) { result.append("b") } 25 | btn.sendActions(for: .touchUpInside) 26 | 27 | XCTAssert(result == "ab") 28 | } 29 | 30 | func testCombinedEvents() { 31 | var result = "" 32 | 33 | let btn = UIButton() 34 | 35 | btn.all_setEventHandler(for: .touchUpInside) { result.append("a") } 36 | btn.all_setEventHandler(for: .touchUpOutside) { result.append("b") } 37 | btn.all_setEventHandler(for: [.touchUpInside, .touchUpOutside]) { result.append("c") } 38 | 39 | btn.sendActions(for: .touchUpInside) 40 | btn.sendActions(for: .touchUpOutside) 41 | 42 | XCTAssert(result == "acbc") 43 | } 44 | } 45 | --------------------------------------------------------------------------------