├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── Builder.xcscheme ├── BuilderDemo ├── BuilderDemo.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ ├── BuilderDemo-Mock.xcscheme │ │ └── BuilderDemo.xcscheme ├── BuilderDemo │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Icon-40.png │ │ │ ├── Icon-40@2x.png │ │ │ ├── Icon-40@3x.png │ │ │ ├── Icon-60@2x.png │ │ │ ├── Icon-60@3x.png │ │ │ ├── Icon-72.png │ │ │ ├── Icon-72@2x.png │ │ │ ├── Icon-76.png │ │ │ ├── Icon-76@2x.png │ │ │ ├── Icon-83.5@2x.png │ │ │ ├── Icon-Small-50.png │ │ │ ├── Icon-Small-50@2x.png │ │ │ ├── Icon-Small.png │ │ │ ├── Icon-Small@2x.png │ │ │ ├── Icon-Small@3x.png │ │ │ ├── Icon.png │ │ │ ├── Icon@2x.png │ │ │ ├── NotificationIcon@2x.png │ │ │ ├── NotificationIcon@3x.png │ │ │ ├── NotificationIcon~ipad.png │ │ │ ├── NotificationIcon~ipad@2x.png │ │ │ └── ios-marketing.png │ │ ├── Contents.json │ │ ├── Logo-DK.imageset │ │ │ ├── Contents.json │ │ │ └── RxSwiftWidgets-Logo-DK.png │ │ ├── Logo.imageset │ │ │ ├── Contents.json │ │ │ └── RxSwiftWidgets-DK.png │ │ ├── User-JQ.imageset │ │ │ ├── Contents.json │ │ │ └── User-JQ.jpg │ │ ├── User-ML.imageset │ │ │ ├── Contents.json │ │ │ └── HML Animoji-G.png │ │ ├── User-TS.imageset │ │ │ ├── Contents.json │ │ │ └── User-TS.png │ │ ├── User-Unknown.imageset │ │ │ ├── Contents.json │ │ │ └── unknown.png │ │ ├── cardImagePlaceholder.imageset │ │ │ ├── Contents.json │ │ │ ├── cardImagePlaceholder.png │ │ │ ├── cardImagePlaceholder@2x.png │ │ │ └── cardImagePlaceholder@3x.png │ │ ├── checkboxChecked.imageset │ │ │ ├── Contents.json │ │ │ ├── checkboxChecked.png │ │ │ ├── checkboxChecked@2x.png │ │ │ └── checkboxChecked@3x.png │ │ ├── checkboxCheckedBlue.imageset │ │ │ ├── Contents.json │ │ │ ├── checkboxCheckedBlue.png │ │ │ ├── checkboxCheckedBlue@2x.png │ │ │ └── checkboxCheckedBlue@3x.png │ │ ├── checkboxEmpty.imageset │ │ │ ├── Contents.json │ │ │ ├── checkboxEmpty.png │ │ │ ├── checkboxEmpty@2x.png │ │ │ └── checkboxEmpty@3x.png │ │ ├── checkboxEmptyBlue.imageset │ │ │ ├── Contents.json │ │ │ ├── checkboxEmptyBlue.png │ │ │ ├── checkboxEmptyBlue@2x.png │ │ │ └── checkboxEmptyBlue@3x.png │ │ ├── close.imageset │ │ │ ├── Contents.json │ │ │ ├── close.png │ │ │ ├── close@2x.png │ │ │ └── close@3x.png │ │ ├── heartEmpty.imageset │ │ │ ├── Contents.json │ │ │ ├── heartEmpty.png │ │ │ ├── heartEmpty@2x.png │ │ │ └── heartEmpty@3x.png │ │ ├── search-template.imageset │ │ │ ├── Contents.json │ │ │ ├── search.png │ │ │ ├── search@2x.png │ │ │ └── search@3x.png │ │ ├── universalGlyphsEmail.imageset │ │ │ ├── Contents.json │ │ │ ├── universalGlyphsEmail.png │ │ │ ├── universalGlyphsEmail@2x.png │ │ │ └── universalGlyphsEmail@3x.png │ │ ├── universalGlyphsHome.imageset │ │ │ ├── Contents.json │ │ │ ├── universalGlyphsHome.png │ │ │ ├── universalGlyphsHome@2x.png │ │ │ └── universalGlyphsHome@3x.png │ │ ├── universalGlyphsPhone.imageset │ │ │ ├── Contents.json │ │ │ ├── universalGlyphsPhone.png │ │ │ ├── universalGlyphsPhone@2x.png │ │ │ └── universalGlyphsPhone@3x.png │ │ ├── vector.imageset │ │ │ ├── Contents.json │ │ │ ├── vector1.jpg │ │ │ └── vector2.jpg │ │ ├── vector1.imageset │ │ │ ├── Contents.json │ │ │ └── vector1.jpg │ │ └── vector2.imageset │ │ │ ├── Contents.json │ │ │ └── vector2.jpg │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Configuration │ │ ├── API.xcconfig │ │ └── MOCK.xcconfig │ ├── ContactForm │ │ ├── ContactFormViewController.swift │ │ ├── ContactFormViewModel.swift │ │ └── FABMenuView.swift │ ├── Details │ │ ├── DetailCardView.swift │ │ ├── DetailViewController.swift │ │ ├── DetailViewModel.swift │ │ └── _Details+Injection.swift │ ├── Forms │ │ ├── FormField.swift │ │ └── FormFieldManager.swift │ ├── Info.plist │ ├── Login │ │ ├── LoadingCardView.swift │ │ ├── LoginCardView.swift │ │ ├── LoginErrorView.swift │ │ ├── LoginStatusView.swift │ │ ├── LoginViewController.swift │ │ ├── LoginViewModel.swift │ │ └── NotYetImplementedCardView.swift │ ├── Main │ │ ├── MainStackViewController.swift │ │ ├── MainUsersStackView.swift │ │ ├── MainUsersTableView.swift │ │ ├── MainViewController.swift │ │ └── MainViewModel.swift │ ├── Menu │ │ ├── Base.lproj │ │ │ └── Main.storyboard │ │ ├── MenuOption.swift │ │ ├── MenuTableViewController.swift │ │ └── MenuViewController.swift │ ├── Models │ │ └── User.swift │ ├── SceneDelegate.swift │ ├── Services │ │ ├── Dismissible.swift │ │ ├── TestServices.swift │ │ ├── UserImageCache.swift │ │ ├── UserService.swift │ │ └── _Services+Injection.swift │ ├── Shared │ │ ├── Extensions │ │ │ ├── Functions+Extensions.swift │ │ │ ├── Rx+Extensions.swift │ │ │ ├── String+Extensions.swift │ │ │ ├── UIColor+Extensions.swift │ │ │ ├── UIImage+Extensions.swift │ │ │ ├── UITextField+Styles.swift │ │ │ ├── UIView+Extensions.swift │ │ │ └── UIViewController+Extensions.swift │ │ ├── Fields │ │ │ ├── BuilderInternalTextField.swift │ │ │ ├── CurrencyTextField.swift │ │ │ ├── MaskedTextField.swift │ │ │ ├── MaxWidthTextField.swift │ │ │ ├── NextAccessoryView.swift │ │ │ └── TextFieldBehaviorAggregator.swift │ │ ├── Networking │ │ │ ├── APIError.swift │ │ │ ├── Core │ │ │ │ ├── BaseSessionManager.swift │ │ │ │ ├── ClientRequestBuilder.swift │ │ │ │ ├── ClientSessionManager.swift │ │ │ │ ├── ClientSessionManagerInterceptor.swift │ │ │ │ └── MockURLProtocol.swift │ │ │ ├── Extensions │ │ │ │ ├── ClientRequestBuilder+Combine.swift │ │ │ │ ├── ClientRequestBuilder+Result.swift │ │ │ │ └── ClientRequestBuilder+RxSwift.swift │ │ │ ├── Inteceptors │ │ │ │ ├── MockDelayInterceptor.swift │ │ │ │ ├── MockSessionManagerWrapper.swift │ │ │ │ ├── SSOAuthenticationInterceptor.swift │ │ │ │ ├── SessionLoggingInterceptorr.swift │ │ │ │ ├── StandardHeadersInterceptor.swift │ │ │ │ └── StatusErrorMappingInterceptor.swift │ │ │ └── _Networking+Injection.swift │ │ └── Styles │ │ │ ├── ButtonView+Styles.swift │ │ │ ├── LabelView+Styles.swift │ │ │ └── TextField+Styles.swift │ ├── Test │ │ ├── CornerCardViewController.swift │ │ ├── GeneralTestView.swift │ │ ├── ScrollingTabBarTest.swift │ │ ├── TabBarTest.swift │ │ ├── TestViewController.swift │ │ └── TestViews.swift │ ├── ViewController.swift │ ├── Views │ │ ├── DLSCardView.swift │ │ ├── MetaTextField.swift │ │ ├── StandardEmptyPage.swift │ │ ├── StandardErrorPage.swift │ │ └── StandardLoadingPage.swift │ ├── _Main+Injection.swift │ └── json │ │ ├── get.json │ │ └── get_users.json ├── BuilderDemoTests │ ├── BuilderDemoTests.swift │ ├── Factory+XCTest.swift │ ├── MainViewModelSpec.swift │ ├── README.md │ ├── TestExrtensionsSpec.swift │ ├── UserImageCacheSpec.swift │ └── XCTTest+Extensions.swift └── BuilderDemoUITests │ ├── BuilderDemoUITests.swift │ └── BuilderDemoUITestsLaunchTests.swift ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── SampleDetail.png ├── Sources └── Builder │ ├── Builder │ ├── Builder+Attributes.swift │ ├── Builder+Button.swift │ ├── Builder+Constraints.swift │ ├── Builder+Container.swift │ ├── Builder+Context.swift │ ├── Builder+Controls.swift │ ├── Builder+Divider.swift │ ├── Builder+Dynamic.swift │ ├── Builder+Extensions.swift │ ├── Builder+ForEach.swift │ ├── Builder+Gestures.swift │ ├── Builder+Group.swift │ ├── Builder+Image.swift │ ├── Builder+Label.swift │ ├── Builder+Navigation.swift │ ├── Builder+Padding.swift │ ├── Builder+RxSwift.swift │ ├── Builder+ScrollView.swift │ ├── Builder+Spacer.swift │ ├── Builder+Stack.swift │ ├── Builder+Styles.swift │ ├── Builder+Switch.swift │ ├── Builder+TableView.swift │ ├── Builder+TextField.swift │ ├── Builder+Variable.swift │ ├── Builder+View.swift │ ├── Builder+ViewController.swift │ ├── Builder+With.swift │ ├── Builder+ZStack.swift │ └── Builder.swift │ └── Extensions │ ├── UIColor+Extensions.swift │ └── UIImage+Extensions.swift └── Tests └── BuilderTests └── BuilderTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | .build/ 41 | 42 | # CocoaPods 43 | # 44 | # We recommend against adding the Pods directory to your .gitignore. However 45 | # you should judge for yourself, the pros and cons are mentioned at: 46 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 47 | # 48 | # Pods/ 49 | 50 | # Carthage 51 | # 52 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 53 | # Carthage/Checkouts 54 | 55 | Carthage/Build 56 | 57 | # fastlane 58 | # 59 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 60 | # screenshots whenever they are needed. 61 | # For more information about the recommended setup visit: 62 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 63 | 64 | fastlane/report.xml 65 | fastlane/Preview.html 66 | fastlane/screenshots 67 | fastlane/test_output 68 | IDEWorkspaceChecks.plist 69 | .DS_Store 70 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/Builder.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "factory", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/hmlongco/Factory.git", 7 | "state" : { 8 | "branch" : "main", 9 | "revision" : "59f9a10aa635a9e34b61e8a1ac43b5798c090093" 10 | } 11 | }, 12 | { 13 | "identity" : "rxswift", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/ReactiveX/RxSwift.git", 16 | "state" : { 17 | "revision" : "b4307ba0b6425c0ba4178e138799946c3da594f8", 18 | "version" : "6.5.0" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // BuilderDemo 4 | // 5 | // Created by Michael Long on 6/18/22. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | 11 | @main 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | // Override point for customization after application launch. 16 | ViewBuilderEnvironment.defaultLabelFont = UIFont.preferredFont(forTextStyle: .body) 17 | 18 | return true 19 | } 20 | 21 | // MARK: UISceneSession Lifecycle 22 | 23 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 24 | // Called when a new scene session is being created. 25 | // Use this method to select a configuration to create the new scene with. 26 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 27 | } 28 | 29 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 30 | // Called when the user discards a scene session. 31 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 32 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 33 | } 34 | 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "NotificationIcon@2x.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "NotificationIcon@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "Icon-Small.png", 17 | "idiom" : "iphone", 18 | "scale" : "1x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "Icon-Small@2x.png", 23 | "idiom" : "iphone", 24 | "scale" : "2x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "Icon-Small@3x.png", 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "29x29" 32 | }, 33 | { 34 | "filename" : "Icon-40@2x.png", 35 | "idiom" : "iphone", 36 | "scale" : "2x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "Icon-40@3x.png", 41 | "idiom" : "iphone", 42 | "scale" : "3x", 43 | "size" : "40x40" 44 | }, 45 | { 46 | "filename" : "Icon.png", 47 | "idiom" : "iphone", 48 | "scale" : "1x", 49 | "size" : "57x57" 50 | }, 51 | { 52 | "filename" : "Icon@2x.png", 53 | "idiom" : "iphone", 54 | "scale" : "2x", 55 | "size" : "57x57" 56 | }, 57 | { 58 | "filename" : "Icon-60@2x.png", 59 | "idiom" : "iphone", 60 | "scale" : "2x", 61 | "size" : "60x60" 62 | }, 63 | { 64 | "filename" : "Icon-60@3x.png", 65 | "idiom" : "iphone", 66 | "scale" : "3x", 67 | "size" : "60x60" 68 | }, 69 | { 70 | "filename" : "NotificationIcon~ipad.png", 71 | "idiom" : "ipad", 72 | "scale" : "1x", 73 | "size" : "20x20" 74 | }, 75 | { 76 | "filename" : "NotificationIcon~ipad@2x.png", 77 | "idiom" : "ipad", 78 | "scale" : "2x", 79 | "size" : "20x20" 80 | }, 81 | { 82 | "filename" : "Icon-Small.png", 83 | "idiom" : "ipad", 84 | "scale" : "1x", 85 | "size" : "29x29" 86 | }, 87 | { 88 | "filename" : "Icon-Small@2x.png", 89 | "idiom" : "ipad", 90 | "scale" : "2x", 91 | "size" : "29x29" 92 | }, 93 | { 94 | "filename" : "Icon-40.png", 95 | "idiom" : "ipad", 96 | "scale" : "1x", 97 | "size" : "40x40" 98 | }, 99 | { 100 | "filename" : "Icon-40@2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "40x40" 104 | }, 105 | { 106 | "filename" : "Icon-Small-50.png", 107 | "idiom" : "ipad", 108 | "scale" : "1x", 109 | "size" : "50x50" 110 | }, 111 | { 112 | "filename" : "Icon-Small-50@2x.png", 113 | "idiom" : "ipad", 114 | "scale" : "2x", 115 | "size" : "50x50" 116 | }, 117 | { 118 | "filename" : "Icon-72.png", 119 | "idiom" : "ipad", 120 | "scale" : "1x", 121 | "size" : "72x72" 122 | }, 123 | { 124 | "filename" : "Icon-72@2x.png", 125 | "idiom" : "ipad", 126 | "scale" : "2x", 127 | "size" : "72x72" 128 | }, 129 | { 130 | "filename" : "Icon-76.png", 131 | "idiom" : "ipad", 132 | "scale" : "1x", 133 | "size" : "76x76" 134 | }, 135 | { 136 | "filename" : "Icon-76@2x.png", 137 | "idiom" : "ipad", 138 | "scale" : "2x", 139 | "size" : "76x76" 140 | }, 141 | { 142 | "filename" : "Icon-83.5@2x.png", 143 | "idiom" : "ipad", 144 | "scale" : "2x", 145 | "size" : "83.5x83.5" 146 | }, 147 | { 148 | "filename" : "ios-marketing.png", 149 | "idiom" : "ios-marketing", 150 | "scale" : "1x", 151 | "size" : "1024x1024" 152 | } 153 | ], 154 | "info" : { 155 | "author" : "xcode", 156 | "version" : 1 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-40.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-72.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-72@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-76.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-Small-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-Small-50.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-Small-50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-Small-50@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-Small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-Small.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/Icon@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/NotificationIcon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/NotificationIcon@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/NotificationIcon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/NotificationIcon@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/NotificationIcon~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/NotificationIcon~ipad.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/NotificationIcon~ipad@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/NotificationIcon~ipad@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/ios-marketing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/AppIcon.appiconset/ios-marketing.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/Logo-DK.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "RxSwiftWidgets-Logo-DK.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/Logo-DK.imageset/RxSwiftWidgets-Logo-DK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/Logo-DK.imageset/RxSwiftWidgets-Logo-DK.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/Logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "RxSwiftWidgets-DK.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/Logo.imageset/RxSwiftWidgets-DK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/Logo.imageset/RxSwiftWidgets-DK.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/User-JQ.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "User-JQ.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/User-JQ.imageset/User-JQ.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/User-JQ.imageset/User-JQ.jpg -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/User-ML.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "HML Animoji-G.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/User-ML.imageset/HML Animoji-G.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/User-ML.imageset/HML Animoji-G.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/User-TS.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "User-TS.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/User-TS.imageset/User-TS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/User-TS.imageset/User-TS.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/User-Unknown.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "unknown.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/User-Unknown.imageset/unknown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/User-Unknown.imageset/unknown.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/cardImagePlaceholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "cardImagePlaceholder.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "cardImagePlaceholder@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "cardImagePlaceholder@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/cardImagePlaceholder.imageset/cardImagePlaceholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/cardImagePlaceholder.imageset/cardImagePlaceholder.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/cardImagePlaceholder.imageset/cardImagePlaceholder@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/cardImagePlaceholder.imageset/cardImagePlaceholder@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/cardImagePlaceholder.imageset/cardImagePlaceholder@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/cardImagePlaceholder.imageset/cardImagePlaceholder@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxChecked.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "checkboxChecked.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "checkboxChecked@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "checkboxChecked@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxChecked.imageset/checkboxChecked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/checkboxChecked.imageset/checkboxChecked.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxChecked.imageset/checkboxChecked@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/checkboxChecked.imageset/checkboxChecked@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxChecked.imageset/checkboxChecked@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/checkboxChecked.imageset/checkboxChecked@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxCheckedBlue.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "checkboxCheckedBlue.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "checkboxCheckedBlue@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "checkboxCheckedBlue@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxCheckedBlue.imageset/checkboxCheckedBlue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/checkboxCheckedBlue.imageset/checkboxCheckedBlue.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxCheckedBlue.imageset/checkboxCheckedBlue@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/checkboxCheckedBlue.imageset/checkboxCheckedBlue@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxCheckedBlue.imageset/checkboxCheckedBlue@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/checkboxCheckedBlue.imageset/checkboxCheckedBlue@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmpty.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "checkboxEmpty.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "checkboxEmpty@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "checkboxEmpty@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmpty.imageset/checkboxEmpty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmpty.imageset/checkboxEmpty.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmpty.imageset/checkboxEmpty@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmpty.imageset/checkboxEmpty@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmpty.imageset/checkboxEmpty@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmpty.imageset/checkboxEmpty@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmptyBlue.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "checkboxEmptyBlue.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "checkboxEmptyBlue@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "checkboxEmptyBlue@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmptyBlue.imageset/checkboxEmptyBlue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmptyBlue.imageset/checkboxEmptyBlue.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmptyBlue.imageset/checkboxEmptyBlue@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmptyBlue.imageset/checkboxEmptyBlue@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmptyBlue.imageset/checkboxEmptyBlue@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/checkboxEmptyBlue.imageset/checkboxEmptyBlue@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/close.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "close.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "close@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "close@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/close.imageset/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/close.imageset/close.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/close.imageset/close@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/close.imageset/close@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/close.imageset/close@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/close.imageset/close@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/heartEmpty.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "heartEmpty.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "heartEmpty@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "heartEmpty@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/heartEmpty.imageset/heartEmpty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/heartEmpty.imageset/heartEmpty.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/heartEmpty.imageset/heartEmpty@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/heartEmpty.imageset/heartEmpty@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/heartEmpty.imageset/heartEmpty@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/heartEmpty.imageset/heartEmpty@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/search-template.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "search.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "search@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "search@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/search-template.imageset/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/search-template.imageset/search.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/search-template.imageset/search@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/search-template.imageset/search@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/search-template.imageset/search@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/search-template.imageset/search@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsEmail.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "universalGlyphsEmail.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "universalGlyphsEmail@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "universalGlyphsEmail@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsEmail.imageset/universalGlyphsEmail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsEmail.imageset/universalGlyphsEmail.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsEmail.imageset/universalGlyphsEmail@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsEmail.imageset/universalGlyphsEmail@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsEmail.imageset/universalGlyphsEmail@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsEmail.imageset/universalGlyphsEmail@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsHome.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "universalGlyphsHome.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "universalGlyphsHome@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "universalGlyphsHome@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsHome.imageset/universalGlyphsHome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsHome.imageset/universalGlyphsHome.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsHome.imageset/universalGlyphsHome@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsHome.imageset/universalGlyphsHome@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsHome.imageset/universalGlyphsHome@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsHome.imageset/universalGlyphsHome@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsPhone.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "universalGlyphsPhone.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "universalGlyphsPhone@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "universalGlyphsPhone@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsPhone.imageset/universalGlyphsPhone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsPhone.imageset/universalGlyphsPhone.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsPhone.imageset/universalGlyphsPhone@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsPhone.imageset/universalGlyphsPhone@2x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsPhone.imageset/universalGlyphsPhone@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/universalGlyphsPhone.imageset/universalGlyphsPhone@3x.png -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/vector.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "vector2.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "filename" : "vector1.jpg", 16 | "idiom" : "universal", 17 | "scale" : "1x" 18 | }, 19 | { 20 | "idiom" : "universal", 21 | "scale" : "2x" 22 | }, 23 | { 24 | "appearances" : [ 25 | { 26 | "appearance" : "luminosity", 27 | "value" : "dark" 28 | } 29 | ], 30 | "idiom" : "universal", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "idiom" : "universal", 35 | "scale" : "3x" 36 | }, 37 | { 38 | "appearances" : [ 39 | { 40 | "appearance" : "luminosity", 41 | "value" : "dark" 42 | } 43 | ], 44 | "idiom" : "universal", 45 | "scale" : "3x" 46 | } 47 | ], 48 | "info" : { 49 | "author" : "xcode", 50 | "version" : 1 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/vector.imageset/vector1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/vector.imageset/vector1.jpg -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/vector.imageset/vector2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/vector.imageset/vector2.jpg -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/vector1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "vector1.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/vector1.imageset/vector1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/vector1.imageset/vector1.jpg -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/vector2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "vector2.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Assets.xcassets/vector2.imageset/vector2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/BuilderDemo/BuilderDemo/Assets.xcassets/vector2.imageset/vector2.jpg -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Configuration/API.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // API.xcconfig 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/18/21. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | 11 | API_BASE_PROTOCOL = https 12 | API_BASE_URL = "//randomuser.me/api" 13 | BUNDLE_SUFFIX = 14 | PRODUCT_SUFFIX = 15 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Configuration/MOCK.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // MOCK.xcconfig 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/18/21. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | 11 | API_BASE_PROTOCOL = mock 12 | API_BASE_URL = "//mock" 13 | BUNDLE_SUFFIX = .mock 14 | PRODUCT_SUFFIX = -MOCK 15 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/ContactForm/ContactFormViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContactFormViewModel.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 12/10/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import RxSwift 11 | 12 | enum ContactFormIDS: String, CaseIterable { 13 | case first 14 | case last 15 | case address1 16 | case address2 17 | case city 18 | case state 19 | case zip 20 | case email 21 | case alternateEmail 22 | case phone 23 | case agree 24 | } 25 | 26 | class ContactFormViewModel: FormFieldManager { 27 | 28 | @Variable var error: String? = nil 29 | 30 | var hideEmailNotRequired: Observable { 31 | Observable.zip(state(.enabled, for: .email), state(.enabled, for: .alternateEmail)) 32 | .map { $0 || $1 } 33 | } 34 | 35 | var termsTextColor: Observable { 36 | error(for: .agree) 37 | .map { $0 == nil ? .label : .red } 38 | } 39 | 40 | func configure() { 41 | fields = [ 42 | FormField(id: ContactFormIDS.first, value: "Michael") 43 | .isRequired(), 44 | 45 | FormField(id: ContactFormIDS.last, value: "Long") 46 | .isRequired(), 47 | 48 | FormField(id: ContactFormIDS.address1, value: "1234 East West St") 49 | .isRequired(), 50 | 51 | FormField(id: ContactFormIDS.address2, value: ""), 52 | 53 | FormField(id: ContactFormIDS.city, value: "Boulder") 54 | .isRequired(), 55 | 56 | FormField(id: ContactFormIDS.state, value: "CO") 57 | .isRequired() 58 | .length(2, "Two letter abbreviation."), 59 | 60 | FormField(id: ContactFormIDS.zip, value: "80303") 61 | .isRequired(), 62 | 63 | FormField(id: ContactFormIDS.email, value: "hmlongexample.com") 64 | .isRequired() 65 | .isEmail(), 66 | 67 | FormField(id: ContactFormIDS.alternateEmail, value: "") 68 | .isEmail(), 69 | 70 | FormField(id: ContactFormIDS.phone, value: "303-895-0000") 71 | .validate { 72 | $0.stripReturningDigitsOnly().count == 10 ? nil : "Phone number must be 10 digits long" 73 | }, 74 | 75 | FormField(id: ContactFormIDS.agree, value: true) 76 | .isRequired() 77 | ] 78 | 79 | // print("INITIAL DISABLE") 80 | // states[ContactFormIDS.email.rawValue] = .disabled 81 | // states[ContactFormIDS.alternateEmail.rawValue] = .disabled 82 | } 83 | 84 | override func placeholder(for id: ContactFormIDS) -> String { 85 | switch id { 86 | case .first: 87 | return "First Name" 88 | case .last: 89 | return "Last Name" 90 | case .address1: 91 | return "Address Line 1" 92 | case .address2: 93 | return "Address Line 2 (optional)" 94 | case .alternateEmail: 95 | return "Alternate Email (optional)" 96 | default: 97 | return id.rawValue.capitalized 98 | } 99 | } 100 | 101 | override func validate() { 102 | super.validate() // default behavior 103 | error = errors.isEmpty ? nil : "Please correct the following errors..." 104 | } 105 | 106 | } 107 | 108 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/ContactForm/FABMenuView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FABMenuView.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 12/15/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import RxSwift 11 | 12 | struct FABMenuView: ViewBuilder { 13 | 14 | let menuItems: [String] 15 | var content: () -> ViewConvertable 16 | 17 | @Variable var showFABMenu = false 18 | 19 | init(menuItems: [String], @ViewResultBuilder _ builder: @escaping () -> ViewConvertable) { 20 | self.menuItems = menuItems 21 | self.content = builder 22 | } 23 | 24 | var body: View { 25 | ZStackView { 26 | content() 27 | 28 | ContainerView { 29 | ImageView(systemName: "plus") 30 | .tintColor(.white) 31 | .contentMode(.center) 32 | .frame(height: 50, width: 50) 33 | .cornerRadius(25) 34 | .backgroundColor(.red) 35 | .position(.topCenter) 36 | .onTapGesture { context in 37 | showFABMenu.toggle() 38 | } 39 | } 40 | .position(.bottomCenter) 41 | .frame(height: 80, width: 50) 42 | 43 | // disabled area 44 | ContainerView { 45 | VStackView { 46 | SpacerView() 47 | 48 | // close area 49 | ZStackView { 50 | ContainerView() 51 | .backgroundColor(.black) 52 | .position(.bottom) 53 | .height(25) 54 | 55 | ImageView(systemName: "xmark") 56 | .tintColor(.white) 57 | .contentMode(.center) 58 | .frame(height: 50, width: 50) 59 | .cornerRadius(25) 60 | .backgroundColor(.red) 61 | .position(.bottomCenter) 62 | .onTapGesture { context in 63 | showFABMenu.toggle() 64 | } 65 | } 66 | 67 | // menu area 68 | ContainerView { 69 | VStackView { 70 | ForEach(menuItems) { item in 71 | LabelView(item) 72 | .color(.white) 73 | .height(44) 74 | .onTapGesture { context in 75 | showFABMenu.toggle() 76 | print("TAPPED \(item)") 77 | } 78 | } 79 | } 80 | .alignment(.center) 81 | .padding(20) 82 | .spacing(0) 83 | } 84 | .backgroundColor(.black) 85 | .position(.bottom) 86 | } 87 | .spacing(0) 88 | } 89 | .backgroundColor(UIColor(white: 0.5, alpha: 0.3)) 90 | .width(UIScreen.main.bounds.width) 91 | .hidden(bind: $showFABMenu.asObservable().map { !$0 }) 92 | .onTapGesture { context in 93 | showFABMenu.toggle() 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Details/DetailCardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailCardView.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/18/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | 13 | struct DetailCardView: ViewBuilder { 14 | 15 | @Injected(Container.detailViewModel) var viewModel: DetailViewModel 16 | 17 | init(user: User) { 18 | viewModel.configure(user) 19 | } 20 | 21 | var body: View { 22 | DLSCardView { 23 | VStackView { 24 | DetailPhotoView(photo: viewModel.photo(), name: viewModel.fullname) 25 | 26 | VStackView { 27 | NameValueView(name: "Address", value: viewModel.street) 28 | NameValueView(name: "", value: viewModel.cityStateZip) 29 | SpacerView(8) 30 | NameValueView(name: "Email", value: viewModel.email) 31 | NameValueView(name: "Phone1", value: viewModel.phone) 32 | SpacerView(8) 33 | NameValueView(name: "Age", value: viewModel.age) 34 | } 35 | .spacing(0) 36 | .padding(20) 37 | } 38 | .onAppear { _ in 39 | print("DLS Card Appeared") 40 | } 41 | .onDisappear { _ in 42 | print("DLS Card Disappeared") 43 | } 44 | } 45 | } 46 | 47 | } 48 | 49 | struct DetailPhotoView: ViewBuilder { 50 | 51 | let photo: Observable 52 | let name: String 53 | 54 | var body: View { 55 | ZStackView { 56 | ImageView(photo) 57 | .contentMode(.scaleAspectFill) 58 | .clipsToBounds(true) 59 | .height(300) 60 | LabelView(name) 61 | .alignment(.right) 62 | .font(.title2) 63 | .color(.white) 64 | .padding(h: 20, v: 8) 65 | .backgroundColor(.black) 66 | .alpha(0.7) 67 | .position(.bottom) 68 | } 69 | } 70 | } 71 | 72 | struct NameValueView: ViewBuilder { 73 | 74 | let name: String? 75 | let value: String? 76 | 77 | var body: View { 78 | HStackView { 79 | LabelView(name) 80 | .color(.secondaryLabel) 81 | SpacerView() 82 | LabelView(value) 83 | .alignment(.right) 84 | } 85 | .spacing(4) 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Details/DetailViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/17/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | import RxCocoa 13 | 14 | class DetailViewController: UIViewController { 15 | 16 | @Injected(Container.detailViewModel) var viewModel: DetailViewModel 17 | 18 | lazy var dismissible = Dismissible(self) 19 | 20 | @Variable var testSwtichValue = false 21 | 22 | convenience init(user: User) { 23 | self.init() 24 | viewModel.configure(user) 25 | } 26 | 27 | deinit { 28 | print("deinit DetailViewController") 29 | } 30 | 31 | override func viewDidLoad() { 32 | super.viewDidLoad() 33 | title = viewModel.fullname 34 | view.embed(contents()) 35 | view.backgroundColor = .systemBackground 36 | } 37 | 38 | func contents() -> View { 39 | VerticalScrollView { 40 | VStackView { 41 | DetailCardView(user: viewModel.user) 42 | 43 | HStackView { 44 | LabelView("Accept Terms") 45 | SpacerView() 46 | SwitchView(viewModel.$accepted) 47 | .onTintColor(.blue) 48 | } 49 | 50 | ButtonView("Submit") 51 | .enabled(bind: viewModel.$accepted) 52 | .style(StyleButtonFilled()) 53 | .onTap { [weak dismissible] _ in 54 | dismissible?.dismiss() 55 | } 56 | 57 | LabelView("Inforamtion presented above is not repesentative of any person, living, dead, undead, or fictional.") 58 | .style(StyleLabelFootnote()) 59 | 60 | SpacerView() 61 | } 62 | .padding(20) 63 | .spacing(20) 64 | .onReceive(viewModel.$accepted) { context in 65 | context.view.accessibilityLabel = context.value ? "Terms accepted." : "Terms Not yet accepted." 66 | } 67 | } 68 | .backgroundColor(.quaternarySystemFill) 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Details/DetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailViewModel.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/17/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | 13 | class DetailViewModel { 14 | 15 | @Injected(Container.userImageCache) var cache: UserImageCache 16 | 17 | @Variable var accepted = false 18 | 19 | var user: User! 20 | 21 | var fullname: String { user.fullname } 22 | 23 | var street: String? { 24 | guard let loc = user.location, let number = loc.street?.number, let name = loc.street?.name else { return nil } 25 | return "\(number) \(name)" 26 | } 27 | 28 | var cityStateZip: String? { 29 | guard let loc = user.location, let city = loc.city, let state = loc.state, let zip = loc.postcode else { return nil } 30 | return "\(city) \(state) \(zip)" 31 | } 32 | 33 | var email: String { user.email ?? "n/a" } 34 | var phone: String { user.phone ?? "n/a"} 35 | 36 | var age: String { 37 | guard let age = user.dob?.age else { return "n/a" } 38 | return "\(age)" 39 | } 40 | 41 | var disposeBag = DisposeBag() 42 | 43 | init() { 44 | $accepted.asObservable() 45 | .debug() 46 | .subscribe { event in 47 | print(event) 48 | } 49 | .disposed(by: disposeBag) 50 | } 51 | 52 | deinit { 53 | print("deinit DetailViewModel") 54 | } 55 | 56 | func configure(_ user: User) { 57 | self.user = user 58 | } 59 | 60 | func photo() -> Observable { 61 | return cache.photo(forUser: user) 62 | .map { $0 ?? UIImage(named: "User-Unknown") } 63 | .observe(on: MainScheduler.instance) 64 | } 65 | 66 | #if MOCK 67 | func configuredViewModel() -> DetailViewModel { 68 | return with(DetailViewModel()) { 69 | $0.configure(User.mockJQ) 70 | } 71 | 72 | } 73 | 74 | func makeButton(_ title: String?) -> UIButton { 75 | with(UIButton()) { 76 | $0.translatesAutoresizingMaskIntoConstraints = false 77 | $0.titleLabel?.text = title 78 | $0.titleLabel?.font = .preferredFont(forTextStyle: .headline) 79 | $0.setTitleColor(.red, for: .normal) 80 | } 81 | } 82 | 83 | func makeButton2(_ title: String?) -> UIButton { 84 | let button = UIButton() 85 | button.translatesAutoresizingMaskIntoConstraints = false 86 | button.titleLabel?.text = title 87 | button.titleLabel?.font = .preferredFont(forTextStyle: .headline) 88 | button.setTitleColor(.red, for: .normal) 89 | return button 90 | } 91 | 92 | #endif 93 | } 94 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Details/_Details+Injection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Details+Injection.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/17/21. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | 11 | extension Container { 12 | static let detailViewModel = Factory(scope: .shared) { 13 | DetailViewModel() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Forms/FormField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormField.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 12/12/21. 6 | // 7 | 8 | import Foundation 9 | import Builder 10 | import RxSwift 11 | import UIKit 12 | 13 | 14 | protocol AnyFormField { 15 | var id: String { get } 16 | func value() -> T? 17 | func validates() -> String? 18 | } 19 | 20 | struct FormField: AnyFormField { 21 | 22 | var id: String 23 | 24 | @Variable var variable: Value 25 | 26 | private(set) var validators: [(Value) -> String?] = [] 27 | 28 | init(id: String, value: Value) { 29 | self.id = id 30 | self._variable = Variable(wrappedValue: value) 31 | } 32 | 33 | init(id: ID, value: Value) where ID.RawValue == String { 34 | self.id = id.rawValue 35 | self._variable = Variable(wrappedValue: value) 36 | } 37 | 38 | func value() -> T? { 39 | variable as? T 40 | } 41 | 42 | func validates() -> String? { 43 | for validator in validators { 44 | if let error = validator(variable) { 45 | return error 46 | } 47 | } 48 | return nil 49 | } 50 | 51 | } 52 | 53 | 54 | extension FormField where Value == String { 55 | 56 | func validate(_ validation: @escaping (_ value: String) -> String?) -> Self { 57 | validateEmpty { $0.isEmpty ? nil : validation($0) } 58 | } 59 | 60 | func validateEmpty(_ validation: @escaping (_ value: String) -> String?) -> Self { 61 | var field = self 62 | field.validators.append(validation) 63 | return field 64 | } 65 | 66 | func isRequired(_ message: String? = nil) -> Self { 67 | validateEmpty { $0.isEmpty ? message ?? "Required" : nil } 68 | } 69 | 70 | func isEmail(_ message: String? = nil) -> Self { 71 | validate { $0 .contains("@") ? nil : message ?? "Must be valid email address" } 72 | } 73 | 74 | func length(_ count: Int, _ message: String? = nil) -> Self { 75 | validate { $0.count == count ? nil : message ?? "Must be \(count) characters long" } 76 | } 77 | 78 | func maxLength(_ count: Int, _ message: String? = nil) -> Self { 79 | validate { $0.count > count ? nil : message ?? "Must be \(count) characters or less" } 80 | } 81 | 82 | func minLength(_ count: Int, _ message: String? = nil) -> Self { 83 | validate { $0.count <= count ? nil : message ?? "Must be \(count) characters or more" } 84 | } 85 | 86 | } 87 | 88 | extension FormField where Value == Bool { 89 | 90 | func validate(_ validation: @escaping (_ value: Bool) -> String?) -> Self { 91 | var field = self 92 | field.validators.append(validation) 93 | return field 94 | } 95 | 96 | func isRequired(_ message: String? = nil) -> Self { 97 | validate { $0 ? nil : message ?? "Required" } 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Forms/FormFieldManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormFieldManager.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 12/12/21. 6 | // 7 | 8 | import Foundation 9 | import Builder 10 | import RxSwift 11 | 12 | enum FormFieldState { 13 | case enabled 14 | case disabled 15 | case hidden 16 | } 17 | 18 | class FormFieldManager where IDS.RawValue == String { 19 | 20 | @Variable var fields: [AnyFormField] = [] 21 | @Variable var errors: [String:String] = [:] 22 | @Variable var states: [String:FormFieldState] = [:] 23 | 24 | @inlinable 25 | func field(for id: IDS) -> AnyFormField? { 26 | fields.first(where: { $0.id == id.rawValue }) 27 | } 28 | 29 | @inlinable 30 | func value(for id: IDS) -> T? { 31 | field(for: id)?.value() 32 | } 33 | 34 | func variable(for id: IDS) -> Variable { 35 | guard let field = field(for: id) as? FormField else { 36 | fatalError("Missing '\(id.rawValue)' of type \(T.self) in field list") 37 | } 38 | return field.$variable 39 | } 40 | 41 | func placeholder(for id: IDS) -> String { 42 | return id.rawValue.capitalized 43 | } 44 | 45 | func error(for id: IDS) -> Observable { 46 | $errors.asObservable() 47 | .map { $0[id.rawValue] } 48 | } 49 | 50 | func hasErrors() -> Observable { 51 | $errors.asObservable() 52 | .skip(1) 53 | .debounce(.milliseconds(100), scheduler: MainScheduler.instance) 54 | .map { !$0.isEmpty } 55 | } 56 | 57 | var isValid: Bool { 58 | errors.isEmpty 59 | } 60 | 61 | func state(for id: IDS) -> Observable { 62 | $states.asObservable() 63 | .map { $0[id.rawValue] ?? .enabled } 64 | .distinctUntilChanged() 65 | } 66 | 67 | func state(_ state: FormFieldState, for id: IDS) -> Observable { 68 | self.state(for: id) 69 | .map { $0 == state } 70 | } 71 | 72 | func validate() { 73 | validate(fields, filtering: [.disabled, .hidden]) 74 | } 75 | 76 | func validate(_ fields: [AnyFormField], filtering states: [FormFieldState]) { 77 | errors = fields 78 | .compactMap { (field) -> AnyFormField? in 79 | let state = self.states[field.id] ?? .enabled 80 | return states.contains(state) ? nil : field 81 | } 82 | .reduce(into: [:]) { errors, field in 83 | if let error = field.validates() { 84 | errors[field.id] = error 85 | } 86 | } 87 | } 88 | 89 | func nextID(from id: IDS) -> String? { 90 | let ids = fields.map { $0.id } 91 | if let index = ids.firstIndex(where: { $0 == id.rawValue }), index < (ids.count - 1) { 92 | return ids[index + 1] 93 | } 94 | return nil 95 | } 96 | 97 | } 98 | 99 | 100 | extension MetaTextField { 101 | 102 | init(manager: FormFieldManager, id: ID) where ID: RawRepresentable, ID.RawValue == String { 103 | self 104 | .identifier(id.rawValue) 105 | .text(bidirectionalBind: manager.variable(for: id)) 106 | .placeholder(manager.placeholder(for: id)) 107 | .error(bind: manager.error(for: id)) 108 | .returnKeyType(.next) 109 | .onControlEvent(.editingDidBegin) { context in 110 | context.view.scrollIntoView() 111 | } 112 | .onControlEvent(.editingDidEndOnExit) { [weak manager] context in 113 | if let id = manager?.nextID(from: id), let field = context.find(id), field.canBecomeFirstResponder { 114 | field.becomeFirstResponder() 115 | } 116 | } 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API_BASE_PROTOCOL 6 | $(API_BASE_PROTOCOL) 7 | API_BASE_URL 8 | $(API_BASE_URL) 9 | UIApplicationSceneManifest 10 | 11 | UIApplicationSupportsMultipleScenes 12 | 13 | UISceneConfigurations 14 | 15 | UIWindowSceneSessionRoleApplication 16 | 17 | 18 | UISceneConfigurationName 19 | Default Configuration 20 | UISceneDelegateClassName 21 | $(PRODUCT_MODULE_NAME).SceneDelegate 22 | UISceneStoryboardFile 23 | Main 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Login/LoadingCardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewController.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/11/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | import RxCocoa 13 | 14 | struct LoadingCardView: ViewBuilder { 15 | var body: View { 16 | DLSCardView { 17 | VStackView { 18 | With(UIActivityIndicatorView()) { 19 | $0.startAnimating() 20 | } 21 | } 22 | .padding(16) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Login/LoginCardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginCardView.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/11/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | import RxCocoa 13 | 14 | struct LoginCardView: ViewBuilder { 15 | 16 | let viewModel: LoginViewModel 17 | 18 | var body: View { 19 | DLSCardView { 20 | VStackView { 21 | LabelView("Welcome") 22 | .alignment(.center) 23 | .font(.headline) 24 | .color(.label) 25 | 26 | LoginErrorView(error: viewModel.$error) 27 | 28 | VStackView { 29 | TextField(viewModel.$username) 30 | .placeholder("Login ID") 31 | .set(keyPath: \.borderStyle, value: .roundedRect) // properties w/o dedicated builder 32 | .set(keyPath: \.textContentType, value: .username) // properties w/o dedicated builder 33 | .tag(456) // testing identifiers 34 | LabelView(viewModel.$usernameError) 35 | .font(.footnote) 36 | .color(.red) 37 | .hidden(bind: viewModel.hideUsernameError) 38 | .padding(h: 8, v: 0) 39 | } 40 | .spacing(2) 41 | 42 | VStackView { 43 | TextField(viewModel.$password) 44 | .placeholder("Password") 45 | .set(keyPath: \.borderStyle, value: .roundedRect) // properties w/o dedicated builder 46 | .set(keyPath: \.textContentType, value: .password) // properties w/o dedicated builder 47 | .set(keyPath: \.isSecureTextEntry, value: true) // properties w/o dedicated builder 48 | LabelView(viewModel.$passwordError) 49 | .font(.footnote) 50 | .color(.red) 51 | .hidden(bind: viewModel.hidePasswordError) 52 | .padding(h: 8, v: 0) 53 | } 54 | .spacing(2) 55 | 56 | VStackView { 57 | ButtonView("Login") { [weak viewModel] _ in 58 | viewModel?.login() 59 | } 60 | .style(StyleButtonFilled()) 61 | 62 | ButtonView("Enroll / Login Help") { context in 63 | print(context.view.find(superview: "dlscard")!) // testing identifiers 64 | print(context.view.find(456)!) // testing identifiers 65 | } 66 | .height(44) 67 | } 68 | .spacing(6) 69 | 70 | } 71 | .padding(top: 30, left: 40, bottom: 16, right: 40) 72 | .spacing(20) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Login/LoginErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginErrorView.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/11/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | import RxCocoa 13 | 14 | struct LoginErrorView: ViewBuilder { 15 | 16 | @Variable var error: String? 17 | 18 | var body: View { 19 | ContainerView { 20 | LabelView($error) 21 | .alignment(.center) 22 | .font(.headline) 23 | .color(.white) 24 | .numberOfLines(0) 25 | } 26 | .backgroundColor(.red) 27 | .cornerRadius(2) 28 | .padding(8) 29 | .hidden(true) 30 | .onReceive($error.asObservable().skip(1), handler: { context in 31 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { 32 | UIView.animate(withDuration: 0.2) { 33 | context.view.isHidden = context.value == nil 34 | } 35 | } 36 | }) 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Login/LoginStatusView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginStatusView.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/11/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | import RxCocoa 13 | 14 | struct LoginStatusView: ViewBuilder { 15 | 16 | @Variable private var status: String? = nil 17 | 18 | var body: View { 19 | LabelView($status) 20 | .alignment(.center) 21 | .font(.body) 22 | .color(.yellow) 23 | .numberOfLines(0) 24 | .hidden(true) 25 | .onReceive($status.asObservable().skip(1), handler: { context in 26 | UIView.animate(withDuration: 0.3) { 27 | context.view.isHidden = context.value == nil 28 | } 29 | }) 30 | .onAppear { _ in 31 | self.load() 32 | } 33 | } 34 | 35 | func load() { 36 | DispatchQueue.main.asyncAfter(deadline: .now() + 4) { 37 | self.status = "This is a system status message that should be shown to the user." 38 | } 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Login/LoginViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/22/21. 6 | // 7 | 8 | import Foundation 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | import SwiftUI 13 | 14 | class LoginViewModel { 15 | 16 | enum State { 17 | case loading 18 | case loaded 19 | } 20 | 21 | @Variable var state: State = .loading 22 | 23 | @Variable var username: String? = "michael" 24 | @Variable var usernameError: String? = nil 25 | 26 | @Variable var password: String? = "" 27 | @Variable var passwordError: String? = nil 28 | 29 | @Variable var done: Bool = false 30 | @Variable var error: String? = nil 31 | 32 | var hideUsernameError: Observable { 33 | $usernameError.asObservable().map { $0 == nil } 34 | } 35 | 36 | var hidePasswordError: Observable { 37 | $passwordError.asObservable().map { $0 == nil } 38 | } 39 | 40 | func login() { 41 | usernameError = (username?.isEmpty ?? true) ? "Required" : nil 42 | passwordError = (password?.isEmpty ?? true) ? "Required" : nil 43 | 44 | let showError = usernameError != nil || passwordError != nil 45 | error = showError ? "This is an error message that should be shown to the user." : nil 46 | 47 | if error == nil { 48 | state = .loading 49 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 50 | self.done = true 51 | } 52 | } 53 | } 54 | 55 | func load() { 56 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 57 | self.state = .loaded 58 | } 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Login/NotYetImplementedCardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotYetImplementedCardView.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/11/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | import RxCocoa 13 | 14 | struct NotYetImplementedCardView: ViewBuilder { 15 | var body: View { 16 | DLSCardView { 17 | VStackView { 18 | LabelView("Not yet implemented...") 19 | .alignment(.center) 20 | .font(.headline) 21 | .color(.secondaryLabel) 22 | } 23 | .padding(16) 24 | } 25 | .height(150) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Main/MainStackViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/17/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | 13 | class MainStackViewController: UIViewController { 14 | 15 | @Injected(Container.mainViewModel) var viewModel: MainViewModel 16 | 17 | let disposeBag = DisposeBag() 18 | 19 | override func viewDidLoad() { 20 | super.viewDidLoad() 21 | title = "Stack View Test" 22 | view.backgroundColor = .systemBackground 23 | setupSubscriptions() 24 | } 25 | 26 | func setupSubscriptions() { 27 | viewModel.$state 28 | .observe(on: MainScheduler.instance) 29 | .subscribe(onNext: { [weak self] (state) in 30 | guard let self = self else { return } 31 | switch state { 32 | case .initial: 33 | self.viewModel.load() 34 | case .loading: 35 | self.transition(to: StandardLoadingPage()) 36 | case .loaded(let users): 37 | self.transition(to: MainUsersStackView(users: users)) 38 | case .empty(let message): 39 | self.transition(to: StandardEmptyPage(message: message)) 40 | case .error(let error): 41 | self.transition(to: StandardErrorPage(error: error)) 42 | } 43 | }) 44 | .disposed(by: disposeBag) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Main/MainUsersStackView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainUsersStackView.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 3/1/21. 6 | // 7 | 8 | // 9 | // MainCardView.swift 10 | // Builder 11 | // 12 | // Created by Michael Long on 1/18/21. 13 | // 14 | 15 | import UIKit 16 | import Builder 17 | import Factory 18 | import RxSwift 19 | 20 | 21 | struct MainUsersStackView: ViewBuilder { 22 | 23 | let users: [User] 24 | 25 | var body: View { 26 | VerticalScrollView { 27 | VStackView(DynamicItemViewBuilder(users) { user in 28 | StackCardView(user: user) 29 | .backgroundColor(.secondarySystemBackground) 30 | .border(color: .gray) 31 | .shadow(color: .black, radius: 3, opacity: 0.2, offset: CGSize(width: 3, height: 3)) 32 | .onTapGesture { context in 33 | let vc = DetailViewController(user: user) 34 | context.present(vc) 35 | } 36 | }) 37 | .padding(h: 16, v: 8) 38 | .spacing(16) 39 | } 40 | } 41 | } 42 | 43 | 44 | struct StackCardView: ViewBuilder { 45 | 46 | @Injected(Container.userImageCache) var cache: UserImageCache 47 | 48 | let user: User 49 | 50 | var body: View { 51 | ContainerView { 52 | HStackView { 53 | ImageView(thumbnail()) 54 | .frame(height: 60, width: 60) 55 | VStackView { 56 | LabelView(user.fullname) 57 | .font(.preferredFont(forTextStyle: .body)) 58 | LabelView(user.email) 59 | .font(.preferredFont(forTextStyle: .footnote)) 60 | .color(.secondaryLabel) 61 | SpacerView() 62 | } 63 | .padding(h: 8, v: 8) 64 | .spacing(4) 65 | 66 | } 67 | } 68 | } 69 | 70 | func thumbnail() -> Observable { 71 | return cache.thumbnailOrPlaceholder(forUser: user) 72 | .asObservable() 73 | .observe(on: MainScheduler.instance) 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Main/MainUsersTableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainUsersView.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 3/1/21. 6 | // 7 | 8 | // 9 | // MainCardView.swift 10 | // Builder 11 | // 12 | // Created by Michael Long on 1/18/21. 13 | // 14 | 15 | import UIKit 16 | import Builder 17 | import Factory 18 | import RxSwift 19 | 20 | 21 | struct MainUsersTableView: ViewBuilder { 22 | 23 | let users: [User] 24 | 25 | var body: View { 26 | TableView(DynamicItemViewBuilder(users) { user in 27 | TableViewCell { 28 | MainCardView(user: user) 29 | } 30 | .accessoryType(.disclosureIndicator) 31 | .onSelect { (context) in 32 | context.push(DetailViewController(user: user)) 33 | return false 34 | } 35 | }) 36 | } 37 | 38 | } 39 | 40 | struct MainCardView: ViewBuilder { 41 | 42 | @Injected(Container.userImageCache) var cache: UserImageCache 43 | 44 | let user: User 45 | 46 | var body: View { 47 | HStackView { 48 | ImageView(thumbnail()) 49 | .cornerRadius(25) 50 | .frame(height: 50, width: 50) 51 | VStackView { 52 | LabelView(user.fullname) 53 | .font(.preferredFont(forTextStyle: .body)) 54 | LabelView(user.email) 55 | .font(.preferredFont(forTextStyle: .footnote)) 56 | .color(.secondaryLabel) 57 | SpacerView() 58 | } 59 | .spacing(4) 60 | } 61 | } 62 | 63 | func thumbnail() -> Observable { 64 | return cache.thumbnailOrPlaceholder(forUser: user) 65 | .asObservable() 66 | .observe(on: MainScheduler.instance) 67 | } 68 | 69 | } 70 | 71 | class UserTableViewCell: BuilderInternalTableViewCell { 72 | 73 | var nameLabel: UILabel? 74 | var emailLabel: UILabel? 75 | 76 | func configure(_ user: User) { 77 | if contentView.subviews.isEmpty { 78 | contentView.embed(body) 79 | } 80 | nameLabel?.text = user.fullname 81 | emailLabel?.text = user.fullname 82 | } 83 | 84 | var body: View { 85 | HStackView { 86 | VStackView { 87 | LabelView("") 88 | .reference(&nameLabel) 89 | .font(.preferredFont(forTextStyle: .body)) 90 | LabelView("") 91 | .reference(&emailLabel) 92 | .font(.preferredFont(forTextStyle: .footnote)) 93 | .color(.secondaryLabel) 94 | SpacerView() 95 | } 96 | .padding(h: 16, v: 8) 97 | .spacing(4) 98 | } 99 | } 100 | 101 | } 102 | 103 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Main/MainViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/17/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | 13 | class MainViewController: UIViewController { 14 | 15 | @Injected(Container.mainViewModel) var viewModel: MainViewModel 16 | 17 | var mainView: UIView? 18 | let disposeBag = DisposeBag() 19 | 20 | override func viewDidLoad() { 21 | super.viewDidLoad() 22 | title = "Table View Test" 23 | setupSubscriptions() 24 | } 25 | 26 | func setupSubscriptions() { 27 | viewModel.$state 28 | .observe(on: MainScheduler.instance) 29 | .subscribe(onNext: { [weak self] (state) in 30 | guard let self = self else { return } 31 | switch state { 32 | case .initial: 33 | self.viewModel.load() 34 | case .loading: 35 | self.transition(to: StandardLoadingPage()) 36 | case .loaded(let users): 37 | self.transition(to: MainUsersTableView(users: users).reference(&self.mainView)) 38 | case .empty(let message): 39 | self.transition(to: StandardEmptyPage(message: message)) 40 | case .error(let error): 41 | self.transition(to: StandardErrorPage(error: error)) 42 | } 43 | }) 44 | .disposed(by: disposeBag) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Main/MainViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainViewModel.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/17/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | import RxCocoa 13 | 14 | class MainViewModel { 15 | 16 | @Injected(Container.userImageCache) var images: UserImageCache 17 | @Injected(Container.userServiceType) var service: UserServiceType 18 | 19 | enum State: Equatable { 20 | case initial 21 | case loading 22 | case loaded([User]) 23 | case empty(String) 24 | case error(String) 25 | } 26 | 27 | @Variable private(set) var state = State.initial 28 | 29 | private var disposeBag = DisposeBag() 30 | 31 | func load() { 32 | state = .loading 33 | service.list() 34 | .map { $0.sorted(by: { ($0.name.last + $0.name.first) < ($1.name.last + $1.name.first) }) } 35 | .subscribe { [weak self] (users) in 36 | if users.isEmpty { 37 | self?.state = .empty("No current users found...") 38 | } else { 39 | self?.state = .loaded(users) 40 | } 41 | } onFailure: { [weak self] (e) in 42 | self?.state = .error(e.localizedDescription) 43 | } 44 | .disposed(by: disposeBag) 45 | } 46 | 47 | func refresh() { 48 | load() 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Menu/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Menu/MenuOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuOption.swift 3 | // BuilderDemo 4 | // 5 | // Created by Michael Long on 9/17/22. 6 | // 7 | 8 | import UIKit 9 | 10 | struct MenuOption { 11 | let name: String 12 | let description: String 13 | let destination: () -> UIViewController 14 | } 15 | 16 | extension MenuOption { 17 | static let options: [MenuOption] = [ 18 | MenuOption(name: "Login View Test", description: "A basic username/password login screen.") { 19 | LoginViewController() 20 | }, 21 | MenuOption(name: "Contact Form Test", description: "A basic contact form screen with a few twists.") { 22 | ContactFormViewController() 23 | }, 24 | MenuOption(name: "Table View Test", description: "A basic master/detail table view with user data pulled from an API.") { 25 | MainViewController() 26 | }, 27 | MenuOption(name: "Stack View Test", description: "A basic master/detail dynamic stack view with user data pulled from an API.") { 28 | MainStackViewController() 29 | }, 30 | MenuOption(name: "Table View Menu Options", description: "An alternative table view that displays our menu options.") { 31 | MenuTableViewController() 32 | }, 33 | MenuOption(name: "Tab Bar Test View", description: "A basic view that tests a new tab bar layour.") { 34 | CustomTabBarViewController() 35 | }, 36 | MenuOption(name: "Test Views", description: "A basic view that tests many elements of ViewBuilder.") { 37 | TestViewController() 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Menu/MenuTableViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuViewController.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/11/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | 13 | class MenuTableViewController: UIViewController { 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = "Builder Demo" 18 | view.backgroundColor = .systemBackground 19 | view.embed(MenuTableView()) 20 | 21 | // let vc = ContactFormViewController() 22 | // navigationController?.pushViewController(vc, animated: false) 23 | } 24 | 25 | } 26 | 27 | struct MenuTableView: ViewBuilder { 28 | 29 | let options = MenuOption.options 30 | 31 | var body: View { 32 | TableView(StaticViewBuilder { 33 | ForEach(options) { 34 | MenuTableViewCell(option: $0) 35 | } 36 | }) 37 | } 38 | 39 | } 40 | 41 | struct MenuTableViewCell: ViewBuilder { 42 | 43 | let option: MenuOption 44 | 45 | var body: View { 46 | TableViewCell { 47 | VStackView { 48 | LabelView(option.name) 49 | .font(.headline) 50 | LabelView(option.description) 51 | .font(.footnote) 52 | .color(.secondaryLabel) 53 | .numberOfLines(0) 54 | } 55 | .spacing(2) 56 | } 57 | .accessoryType(.disclosureIndicator) 58 | .onSelect { (context) in 59 | context.push(option.destination()) 60 | return false 61 | } 62 | } 63 | 64 | } 65 | 66 | struct MenuTableViewOLD: ViewBuilder { 67 | 68 | var body: View { 69 | TableView(StaticViewBuilder { 70 | MenuTableViewCellOLD(name: "Login View Test", description: "A basic login field.") { 71 | LoginViewController() 72 | } 73 | 74 | MenuTableViewCellOLD(name: "Table View Test", description: "A basic master/detail table view with user data pulled from an API.") { 75 | MainViewController() 76 | } 77 | MenuTableViewCellOLD(name: "Stack View Test", description: "A basic master/detail dynamic stack view with user data pulled from an API.") { 78 | MainStackViewController() 79 | } 80 | MenuTableViewCellOLD(name: "Test Views", description: "A basic view that tests many elements of ViewBuilder.") { 81 | TestViewController() 82 | } 83 | }) 84 | } 85 | 86 | } 87 | 88 | struct MenuTableViewCellOLD: ViewBuilder { 89 | 90 | let name: String 91 | let description: String 92 | let destination: () -> UIViewController 93 | 94 | var body: View { 95 | TableViewCell { 96 | VStackView { 97 | LabelView(name) 98 | .font(.headline) 99 | LabelView(description) 100 | .font(.footnote) 101 | .color(.secondaryLabel) 102 | .numberOfLines(0) 103 | } 104 | .spacing(2) 105 | } 106 | .accessoryType(.disclosureIndicator) 107 | .onSelect { (context) in 108 | context.push(destination()) 109 | return false 110 | } 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Menu/MenuViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuViewController.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/11/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | 13 | class MenuViewController: UIViewController { 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | 18 | navigationItem.titleView = ImageView(named: "Logo-DK") 19 | .frame(height: 50, width: 50) 20 | .build() 21 | 22 | view.backgroundColor = .systemBackground 23 | view.embed(MenuStackView()) 24 | 25 | // let vc = ContactFormViewController() 26 | // navigationController?.pushViewController(vc, animated: false) 27 | } 28 | 29 | } 30 | 31 | struct MenuStackView: ViewBuilder { 32 | var body: View { 33 | ZStackView { 34 | ImageView(named: "vector") 35 | VerticalScrollView { 36 | VStackView { 37 | MenuHeaderView() 38 | ForEach(MenuOption.options) { option in 39 | MenuOptionView(option: option) 40 | } 41 | MenuFootnoteView() 42 | } 43 | .spacing(15) 44 | .padding(30) 45 | } 46 | } 47 | } 48 | } 49 | 50 | struct MenuHeaderView: ViewBuilder { 51 | var body: View { 52 | VStackView { 53 | LabelView("Builder Demo") 54 | .font(.title1) 55 | LabelView("Version 1.0") 56 | .font(.footnote) 57 | .color(.secondaryLabel) 58 | } 59 | .alignment(.center) 60 | .spacing(0) 61 | } 62 | } 63 | 64 | struct MenuOptionView: ViewBuilder { 65 | let option: MenuOption 66 | var body: View { 67 | ContainerView { 68 | VStackView { 69 | LabelView(option.name) 70 | .font(.headline) 71 | LabelView(option.description) 72 | .font(.footnote) 73 | .color(.secondaryLabel) 74 | .numberOfLines(0) 75 | } 76 | .spacing(2) 77 | .padding(16) 78 | } 79 | .backgroundColor(UIColor.systemGroupedBackground.withAlphaComponent(0.5)) 80 | .cornerRadius(10) 81 | .shadow(color: .black.withAlphaComponent(0.5), radius: 3, offset: CGSize(width: 3, height: 3)) 82 | .onTapGesture { context in 83 | context.view.addHighlightOverlay(animated: true, removeAfter: 0.3) 84 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { 85 | context.push(option.destination()) 86 | } 87 | } 88 | } 89 | } 90 | 91 | struct MenuFootnoteView: ViewBuilder { 92 | var body: View { 93 | LabelView("Created by Michael Long, 2022") 94 | .alignment(.center) 95 | .font(.footnote) 96 | .color(.secondaryLabel) 97 | } 98 | } 99 | 100 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // BuilderDemo 4 | // 5 | // Created by Michael Long on 6/18/22. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | 14 | 15 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 16 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 17 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 18 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 19 | guard let _ = (scene as? UIWindowScene) else { return } 20 | } 21 | 22 | func sceneDidDisconnect(_ scene: UIScene) { 23 | // Called as the scene is being released by the system. 24 | // This occurs shortly after the scene enters the background, or when its session is discarded. 25 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 26 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 27 | } 28 | 29 | func sceneDidBecomeActive(_ scene: UIScene) { 30 | // Called when the scene has moved from an inactive state to an active state. 31 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 32 | } 33 | 34 | func sceneWillResignActive(_ scene: UIScene) { 35 | // Called when the scene will move from an active state to an inactive state. 36 | // This may occur due to temporary interruptions (ex. an incoming phone call). 37 | } 38 | 39 | func sceneWillEnterForeground(_ scene: UIScene) { 40 | // Called as the scene transitions from the background to the foreground. 41 | // Use this method to undo the changes made on entering the background. 42 | } 43 | 44 | func sceneDidEnterBackground(_ scene: UIScene) { 45 | // Called as the scene transitions from the foreground to the background. 46 | // Use this method to save data, release shared resources, and store enough scene-specific state information 47 | // to restore the scene back to its current state. 48 | } 49 | 50 | 51 | } 52 | 53 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Services/TestServices.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestServices.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 10/5/21. 6 | // 7 | 8 | import Foundation 9 | import Builder 10 | import RxSwift 11 | import RxCocoa 12 | 13 | class TestVariable { 14 | 15 | @Variable var name = "Michael" 16 | @Variable var switched = true 17 | 18 | func test() { 19 | _ = SwitchView($switched) 20 | 21 | _ = $switched.asObservable() 22 | .subscribe { name in 23 | print(name) 24 | } 25 | 26 | _ = $name.asObservable() 27 | .subscribe { name in 28 | print(name) 29 | } 30 | 31 | name = "Test1" 32 | 33 | $name.wrappedValue = "Test2" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Services/UserImageCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserImageCache.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/17/21. 6 | // 7 | 8 | import UIKit 9 | import Factory 10 | import RxSwift 11 | 12 | class UserImageCache { 13 | 14 | @Injected(Container.userServiceType) var userService: UserServiceType 15 | 16 | func thumbnail(forUser user: User) -> Observable { 17 | guard let path = user.picture?.medium else { 18 | return .just(nil) 19 | } 20 | if let image = imageCache.object(forKey: NSString(string: path)) { 21 | return .just(image) 22 | } 23 | let image = userService.thumbnail(forUser: user) 24 | .asObservable() 25 | return Observable.merge(.just(nil), image) 26 | .do(onNext: { [weak self] (image) in 27 | if let image = image { 28 | self?.imageCache.setObject(image, forKey: NSString(string: path)) 29 | } 30 | }) 31 | } 32 | 33 | func thumbnailOrPlaceholder(forUser user: User) -> Observable { 34 | thumbnail(forUser: user) 35 | .catchAndReturn(UIImage(named: "User-Unknown")) 36 | .map { $0 ?? UIImage(named: "User-Unknown") } 37 | } 38 | 39 | func photo(forUser user: User) -> Observable { 40 | guard let path = user.picture?.large else { 41 | return .just(nil) 42 | } 43 | if let image = imageCache.object(forKey: NSString(string: path)) { 44 | return .just(image) 45 | } 46 | let image = userService.photo(forUser: user) 47 | .asObservable() 48 | return Observable.merge(.just(nil), image) 49 | .do(onNext: { [weak self] (image) in 50 | if let image = image { 51 | self?.imageCache.setObject(image, forKey: NSString(string: path)) 52 | } 53 | }) 54 | } 55 | 56 | func photoOrPlaceholder(forUser user: User) -> Observable { 57 | photo(forUser: user) 58 | .catchAndReturn(UIImage(named: "User-Unknown")) 59 | .map { $0 ?? UIImage(named: "User-Unknown") } 60 | } 61 | 62 | private var imageCache = NSCache() 63 | 64 | } 65 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Services/UserService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserService.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/17/21. 6 | // 7 | 8 | import UIKit 9 | import Factory 10 | import RxSwift 11 | import RxCocoa 12 | 13 | protocol UserServiceType { 14 | func list() -> Single<[User]> 15 | func thumbnail(forUser user: User) -> Single 16 | func photo(forUser user: User) -> Single 17 | } 18 | 19 | struct UserService: UserServiceType { 20 | 21 | @Injected(Container.clientSessionManager) var session: ClientSessionManager 22 | 23 | init() { 24 | session.configure { (interceptor: SSOAuthenticationInterceptor) in 25 | interceptor.token = "F129038AF30912830E8120938" 26 | } 27 | } 28 | 29 | func list() -> Single<[User]> { 30 | session.builder() 31 | .add(queryItems: [ 32 | URLQueryItem(name: "results", value: "20"), 33 | URLQueryItem(name: "seed", value: "999"), 34 | URLQueryItem(name: "nat", value: "us") 35 | ]) 36 | .with { r in 37 | print(r) 38 | } 39 | .get() 40 | .decode(type: UserResultType.self, decoder: JSONDecoder()) 41 | .map { $0.results } 42 | .observe(on: MainScheduler.instance) 43 | } 44 | 45 | func thumbnail(forUser user: User) -> Single { 46 | guard let path = user.picture?.medium else { 47 | return .just(nil) 48 | } 49 | return session.builder(forURL: URL(string: path)) 50 | .get() 51 | .map { (data: Data) -> UIImage? in UIImage(data: data) } 52 | .observe(on: MainScheduler.instance) 53 | } 54 | 55 | func photo(forUser user: User) -> Single { 56 | guard let path = user.picture?.large else { 57 | return .just(nil) 58 | } 59 | return session.builder(forURL: URL(string: path)) 60 | .get() 61 | .map { (data: Data) -> UIImage? in UIImage(data: data) } 62 | .observe(on: MainScheduler.instance) 63 | } 64 | 65 | private struct UserResultType: Codable { 66 | let results: [User] 67 | } 68 | 69 | } 70 | 71 | #if MOCK 72 | struct MockUserService: UserServiceType { 73 | 74 | func list() -> Single<[User]> { 75 | return .just(User.users) 76 | } 77 | 78 | func thumbnail(forUser user: User) -> Single { 79 | if let name = user.picture?.thumbnail, let image = UIImage(named: name) { 80 | return .just(image) 81 | } 82 | return .just(nil) 83 | } 84 | 85 | func photo(forUser user: User) -> Single { 86 | if let name = user.picture?.thumbnail, let image = UIImage(named: name) { 87 | return .just(image) 88 | } 89 | return .just(nil) 90 | } 91 | 92 | } 93 | 94 | struct MockEmptyUserService: UserServiceType { 95 | func list() -> Single<[User]> { 96 | return .just([]) 97 | } 98 | func thumbnail(forUser user: User) -> Single { 99 | return .just(nil) 100 | } 101 | func photo(forUser user: User) -> Single { 102 | return .just(nil) 103 | } 104 | } 105 | 106 | struct MockErrorUserService: UserServiceType { 107 | func list() -> Single<[User]> { 108 | return .error(APIError.unexpected) 109 | } 110 | func thumbnail(forUser user: User) -> Single { 111 | return .error(APIError.unexpected) 112 | } 113 | func photo(forUser user: User) -> Single { 114 | return .error(APIError.unexpected) 115 | } 116 | } 117 | #endif 118 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Services/_Services+Injection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Services+Injection.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/17/21. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | 11 | extension Container { 12 | static let userImageCache = Factory(scope: .shared) { 13 | UserImageCache() 14 | } 15 | static let userServiceType = Factory { 16 | UserService() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Extensions/Functions+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Functions+Extensions.swift 3 | // Functions+Extensions 4 | // 5 | // Created by Michael Long on 7/29/21. 6 | // 7 | 8 | import Foundation 9 | 10 | @discardableResult 11 | @inlinable 12 | public func with(_ value: T, _ configuration: ((_ value: T) -> Void)) -> T { 13 | configuration(value) 14 | return value 15 | } 16 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Extensions/Rx+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Rx+Extensions.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 2/3/21. 6 | // 7 | 8 | import Foundation 9 | import RxSwift 10 | 11 | extension RxSwift.ObservableType where Element == Bool { 12 | func toggle() -> Observable { 13 | self.map { !$0 } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Extensions/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Extensions.swift 3 | // 4 | // Copyright (c) 2017 Client Resources Inc. All rights reserved. 5 | // 6 | 7 | import Foundation 8 | 9 | extension String { 10 | func removingWhitespaces() -> String { 11 | return components(separatedBy: .whitespaces).joined() 12 | } 13 | 14 | // Returns: A formatted phone number in the format: (XXX) XXX-XXXX 15 | func asPhoneNumber() -> String { 16 | return self.formatDigitsWith(mask:"(999) 999-9999") 17 | } 18 | 19 | // Returns: A formatted phone number in the format: XXX-XXX-XXXX 20 | // This is used for when we need a URL of the number to call 21 | func asCallablePhoneNumber() -> String { 22 | return self.formatDigitsWith(mask: "999-999-9999") 23 | } 24 | } 25 | 26 | extension String { 27 | func stripReturningDigitsOnly() -> String { 28 | return self.components(separatedBy: CharacterSet.decimalDigits.inverted).joined() 29 | } 30 | 31 | func replaceNewlinesWithSpaces() -> String { 32 | return self.replacingOccurrences(of: "\n", with: " ") 33 | } 34 | 35 | func formatDigitsWith(mask: String) -> String { 36 | let cleanNumber = self.stripReturningDigitsOnly() 37 | var result = "" 38 | var index = cleanNumber.startIndex 39 | for ch in mask { 40 | if index == cleanNumber.endIndex { 41 | break 42 | } 43 | if ch == "9" { 44 | result.append(cleanNumber[index]) 45 | index = cleanNumber.index(after: index) 46 | } else { 47 | result.append(ch) 48 | } 49 | } 50 | return result 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Extensions/UIColor+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor+Extensions.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/26/21. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIColor { 11 | 12 | func lighter(by percentage: CGFloat = 30.0) -> UIColor { 13 | return self.adjust(by: abs(percentage)) 14 | } 15 | 16 | func darker(by percentage: CGFloat = 30.0) -> UIColor { 17 | return self.adjust(by: -1 * abs(percentage)) 18 | } 19 | 20 | private func adjust(by percentage: CGFloat = 30.0) -> UIColor { 21 | var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 22 | if self.getRed(&red, green: &green, blue: &blue, alpha: &alpha) { 23 | return UIColor(red: min(red + percentage/100, 1.0), 24 | green: min(green + percentage/100, 1.0), 25 | blue: min(blue + percentage/100, 1.0), 26 | alpha: alpha) 27 | } 28 | return self 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Extensions/UIImage+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+Extensions.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 9/26/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIImage { 12 | 13 | /** 14 | Initializes and returns the image object of the specified color and size. 15 | */ 16 | public convenience init?(color: UIColor, size: CGSize = CGSize(width: 1, height: 1)) { 17 | let rect = CGRect(origin: .zero, size: size) 18 | UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0) 19 | color.setFill() 20 | UIRectFill(rect) 21 | let image = UIGraphicsGetImageFromCurrentImageContext() 22 | UIGraphicsEndImageContext() 23 | 24 | guard let cgImage = image?.cgImage else { 25 | return nil 26 | } 27 | self.init(cgImage: cgImage) 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Extensions/UITextField+Styles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITextField+Styles.swift 3 | // 4 | 5 | import Foundation 6 | import UIKit 7 | 8 | private var bottomLineColorAssociationKey: UInt8 = 0 9 | private var selectedBottomLineColorAssociationKey: UInt8 = 1 10 | private var errorColorAssociationKey: UInt8 = 2 11 | 12 | // Custom UIAppearance properties 13 | extension UITextField { 14 | 15 | override open var accessibilityValue: String? { 16 | get { return self.text } 17 | set { super.accessibilityValue = newValue } 18 | } 19 | 20 | @objc dynamic var placeholderTextColor: UIColor? { 21 | get { 22 | return self.placeholderTextColor 23 | } 24 | set { 25 | let placeholderText = placeholder != nil ? placeholder! : "" 26 | attributedPlaceholder = NSAttributedString(string: placeholderText, attributes: [NSAttributedString.Key.foregroundColor: newValue!]) 27 | setNeedsDisplay() 28 | } 29 | } 30 | 31 | @objc dynamic var bottomLineColor: UIColor? { 32 | get { 33 | return objc_getAssociatedObject(self, &bottomLineColorAssociationKey) as? UIColor 34 | } 35 | set(newValue) { 36 | objc_setAssociatedObject(self, &bottomLineColorAssociationKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) 37 | setNeedsDisplay() 38 | } 39 | } 40 | 41 | @objc dynamic var selectedBottomLineColor: UIColor? { 42 | get { 43 | return objc_getAssociatedObject(self, &selectedBottomLineColorAssociationKey) as? UIColor 44 | } 45 | set(newValue) { 46 | objc_setAssociatedObject(self, &selectedBottomLineColorAssociationKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) 47 | setNeedsDisplay() 48 | } 49 | } 50 | 51 | @objc dynamic var errorColor: UIColor? { 52 | get { 53 | return objc_getAssociatedObject(self, &errorColorAssociationKey) as? UIColor 54 | } 55 | set(newValue) { 56 | objc_setAssociatedObject(self, &errorColorAssociationKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) 57 | setNeedsDisplay() 58 | } 59 | 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Extensions/UIView+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView+Extensions.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 12/7/21. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIView { 11 | 12 | public func addUnderline(lineThickness: CGFloat, lineColor: UIColor, dashed: Bool = false) -> CALayer { 13 | 14 | let width = frame.size.width 15 | let height = frame.size.height 16 | 17 | // Create a line at the bottom of the view 18 | let lineShape = CAShapeLayer() 19 | lineShape.strokeStart = 0.0 20 | lineShape.strokeColor = lineColor.cgColor 21 | lineShape.fillColor = UIColor.clear.cgColor 22 | lineShape.lineWidth = lineThickness 23 | 24 | // We might draw the line with a dashed style 25 | if dashed { 26 | let dashThickness = lineThickness as NSNumber 27 | let emptyThickness = lineThickness * 4 as NSNumber 28 | lineShape.lineDashPattern = [dashThickness, emptyThickness] 29 | } 30 | 31 | let path = CGMutablePath() 32 | path.move(to: CGPoint(x: 0, y: height - (lineThickness / 2))) 33 | path.addLine(to: CGPoint(x: width, y: height - (lineThickness / 2))) 34 | lineShape.path = path 35 | 36 | // Modify this view with a clear background 37 | backgroundColor = UIColor.clear 38 | 39 | // Attach the line at the bottom of the ivew 40 | layer.addSublayer(lineShape) 41 | 42 | return lineShape 43 | } 44 | 45 | } 46 | 47 | 48 | extension UIView { 49 | 50 | func addHighlightOverlay(animated: Bool = true, removeAfter delay: TimeInterval? = nil) { 51 | let overlay = UIView(frame: self.bounds) 52 | overlay.backgroundColor = .label 53 | overlay.alpha = animated ? 0.0 : 0.15 54 | overlay.tag = 999 55 | addSubview(overlay) 56 | if animated { 57 | UIView.animate(withDuration: 0.1) { 58 | overlay.alpha = 0.15 59 | } 60 | } 61 | if let delay = delay { 62 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 63 | if animated { 64 | UIView.animate(withDuration: 0.1) { 65 | overlay.alpha = 0.15 66 | } completion: { _ in 67 | overlay.removeFromSuperview() 68 | } 69 | } else { 70 | overlay.removeFromSuperview() 71 | } 72 | } 73 | } 74 | } 75 | 76 | func removeHighlightOverlay(animated: Bool = true) { 77 | if let overlay = find(subview: 999) { 78 | overlay.removeFromSuperview() 79 | } 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Extensions/UIViewController+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController+Extensions.swift 3 | // UIViewController+Extensions 4 | // 5 | // Created by Michael Long on 8/10/21. 6 | // 7 | 8 | import UIKit 9 | 10 | //extension UIStoryboard { 11 | // static func instantiate(_ identifier: String, storyboard: String, configure: (_ vc: VC) -> Void) -> UIViewController { 12 | // let sb = UIStoryboard(name: storyboard, bundle: .main) 13 | // let vc = sb.instantiateViewController(identifier: identifier) 14 | // if let vc = vc as? VC { 15 | // configure(vc) 16 | // } else if let nc = vc as? UINavigationController, let vc = nc.topViewController as? VC { 17 | // configure(vc) 18 | // } 19 | // return vc 20 | // } 21 | //} 22 | // 23 | //extension UIViewController { 24 | // 25 | // func push(_ identifier: String, storyboard: String, configure: (_ vc: VC) -> Void) { 26 | // let vc = UIStoryboard.instantiate(identifier, storyboard: storyboard, configure: configure) 27 | // navigationController?.pushViewController(vc, animated: true) 28 | // } 29 | // 30 | // func present(_ identifier: String, storyboard: String, style: UIModalPresentationStyle = .pageSheet, configure: (_ vc: VC) -> Void) { 31 | // let vc = UIStoryboard.instantiate(identifier, storyboard: storyboard, configure: configure) 32 | // vc.modalPresentationStyle = style 33 | // navigationController?.present(vc, animated: true) 34 | // } 35 | // 36 | //} 37 | // 38 | //extension UIViewController { 39 | // 40 | // func push(_ vc: VC, configure: ((_ vc: VC) -> Void)? = nil) { 41 | // configure?(vc) 42 | // navigationController?.pushViewController(vc, animated: true) 43 | // } 44 | // 45 | // func present(_ vc: VC, style: UIModalPresentationStyle = .pageSheet, configure: ((_ vc: VC) -> Void)? = nil) { 46 | // configure?(vc) 47 | // vc.modalPresentationStyle = style 48 | // navigationController?.present(vc, animated: true) 49 | // } 50 | // 51 | // func present(wrapped vc: VC, style: UIModalPresentationStyle = .pageSheet, configure: ((_ vc: VC) -> Void)? = nil) { 52 | // let nc = UINavigationController(rootViewController: vc) 53 | // configure?(vc) 54 | // vc.modalPresentationStyle = style 55 | // navigationController?.present(nc, animated: true) 56 | // } 57 | // 58 | //} 59 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Fields/MaskedTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/26/21. 6 | // 7 | 8 | 9 | import UIKit 10 | 11 | class MaskedTextField: BuilderInternalTextField { 12 | 13 | var maskFormat: String? { 14 | didSet { 15 | if let maskFormat = maskFormat { 16 | self.behavior = MaskedTextFieldBehavior(maskFormat) 17 | } else { 18 | self.behavior = nil 19 | } 20 | } 21 | } 22 | 23 | } 24 | 25 | class MaskedTextFieldBehavior: TextFieldBehaviorDelegate { 26 | 27 | var maskFormat: String? 28 | 29 | init(_ maskFormat: String?) { 30 | self.maskFormat = maskFormat 31 | } 32 | 33 | func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { 34 | 35 | let oldText = (textField.text ?? "") as NSString 36 | let newText: String = oldText.replacingCharacters(in: range, with: string) 37 | 38 | // masking? 39 | if let mask = maskFormat, !mask.isEmpty { 40 | let maskedText = newText.formatDigitsWith(mask: mask) 41 | if oldText as String != maskedText { 42 | textField.text = maskedText 43 | textField.sendActions(for: .editingChanged) 44 | } 45 | return false 46 | } 47 | 48 | return true 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Fields/MaxWidthTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/26/21. 6 | // 7 | 8 | 9 | import UIKit 10 | 11 | class MaxWidthTextField: BuilderInternalTextField { 12 | 13 | var maxWidth: Int = 0 { 14 | didSet { 15 | if maxWidth > 0 { 16 | self.behavior = MaxWidthTextFieldBehavior(maxWidth) 17 | } else { 18 | self.behavior = nil 19 | } 20 | } 21 | } 22 | 23 | } 24 | 25 | class MaxWidthTextFieldBehavior: TextFieldBehaviorDelegate { 26 | 27 | var maxWidth: Int = 0 28 | 29 | init(_ maxWidth: Int) { 30 | self.maxWidth = maxWidth 31 | } 32 | 33 | public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { 34 | 35 | let oldText = (textField.text ?? "") as NSString 36 | let newText = oldText.replacingCharacters(in: range, with: string) as NSString 37 | 38 | if maxWidth > 0 && newText.length > maxWidth { 39 | return false 40 | } 41 | 42 | return true 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Fields/NextAccessoryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/26/21. 6 | // 7 | 8 | import UIKit 9 | 10 | class NextAccessoryView: UIToolbar { 11 | 12 | weak var textField: UITextField? 13 | 14 | static func add(to field: UITextField) { 15 | _ = NextAccessoryView(field) 16 | } 17 | 18 | init(_ textField: UITextField) { 19 | self.textField = textField 20 | super.init(frame: CGRect(x: 0, y: 0, width: 320, height: 50)) 21 | 22 | barStyle = .default 23 | barTintColor = .lightGray 24 | tintColor = .black 25 | 26 | let flexSpace = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.flexibleSpace, target: nil, action: nil) 27 | let title = textField.returnKeyType == .next ? "Next" : "Done" 28 | let next: UIBarButtonItem = UIBarButtonItem(title: title, style: .plain, target: self, action: #selector(nextButtonAction)) 29 | next.setTitleTextAttributes([NSAttributedString.Key.foregroundColor: UIColor.black], for: .normal) 30 | 31 | var items = [UIBarButtonItem]() 32 | items.append(flexSpace) 33 | items.append(next) 34 | 35 | self.items = items 36 | sizeToFit() 37 | 38 | textField.inputAccessoryView = self 39 | } 40 | 41 | required init?(coder aDecoder: NSCoder) { 42 | super.init(coder: aDecoder) 43 | } 44 | 45 | @objc func nextButtonAction() { 46 | textField?.resignFirstResponder() 47 | textField?.sendActions(for: .editingDidEndOnExit) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Fields/TextFieldBehaviorAggregator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/26/21. 6 | // 7 | 8 | import UIKit 9 | 10 | class TextFieldBehaviorAggregator: TextFieldBehaviorDelegate { 11 | 12 | private let behaviorList: [TextFieldBehaviorDelegate] 13 | 14 | init(behaviors: TextFieldBehaviorDelegate...) { 15 | self.behaviorList = behaviors 16 | } 17 | 18 | func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { 19 | for behavior in self.behaviorList { 20 | if !behavior.textField(textField, shouldChangeCharactersIn: range, replacementString: string) { 21 | return false 22 | } 23 | } 24 | return true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/APIError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIError.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/18/21. 6 | // 7 | 8 | import Foundation 9 | 10 | enum APIError: Error { 11 | case application 12 | case connection 13 | case server 14 | case unknown 15 | case unexpected 16 | } 17 | 18 | extension APIError: CustomStringConvertible { 19 | var description: String { 20 | switch self { 21 | case .connection: 22 | return "Unable to connect to server at this time." 23 | case .unknown: 24 | return "An unknown error occurred." 25 | default: 26 | return "An unexpected error occurred." 27 | } 28 | } 29 | } 30 | 31 | extension APIError: LocalizedError { 32 | public var errorDescription: String? { 33 | return self.description 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/Core/BaseSessionManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseSessionManager.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/19/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class BaseSessionManager: ClientSessionManager { 12 | 13 | var base: String 14 | var session: URLSession 15 | 16 | init(base: String, session: URLSession = URLSession.shared) { 17 | self.base = base 18 | self.session = session 19 | } 20 | 21 | func request(forURL url: URL?) -> URLRequest { 22 | URLRequest(url: url ?? URL(string: base)!) 23 | } 24 | 25 | func execute(request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask? { 26 | session.dataTask(with: request, completionHandler: completionHandler) 27 | } 28 | 29 | } 30 | 31 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/Core/ClientSessionManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClientSessionManager.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/19/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxSwift 11 | 12 | 13 | protocol ClientSessionManager: AnyObject { 14 | 15 | func builder(forURL url: URL?) -> ClientRequestBuilder 16 | 17 | func request(forURL url: URL?) -> URLRequest 18 | func execute(request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask? 19 | 20 | func interceptor(_ interceptor: ClientSessionManagerInterceptor) -> ClientSessionManager 21 | func configure(_ handler: (_ interceptor: T) -> Void) 22 | 23 | } 24 | 25 | extension ClientSessionManager { 26 | 27 | func interceptor(_ parent: ClientSessionManagerInterceptor) -> ClientSessionManager { 28 | parent.parentSessionManager = self 29 | return parent 30 | } 31 | 32 | func configure(_ handler: (_ manager: T) -> Void) { 33 | if let manager = self as? T { 34 | handler(manager) 35 | } 36 | } 37 | 38 | func builder() -> ClientRequestBuilder { 39 | ClientRequestBuilder(self) 40 | } 41 | 42 | func builder(forURL url: URL?) -> ClientRequestBuilder { 43 | ClientRequestBuilder(self, forURL: url) 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/Core/ClientSessionManagerInterceptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClientSessionManagerInterceptor.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/19/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxSwift 11 | 12 | 13 | protocol ClientSessionManagerInterceptor: ClientSessionManager { 14 | var parentSessionManager: ClientSessionManager! { get set } 15 | } 16 | 17 | extension ClientSessionManagerInterceptor { 18 | 19 | func configure(_ handler: (_ manager: T) -> Void) { 20 | if let manager = self as? T { 21 | handler(manager) 22 | return 23 | } 24 | return parentSessionManager.configure(handler) 25 | } 26 | 27 | func request(forURL url: URL?) -> URLRequest { 28 | return parentSessionManager.request(forURL: url) 29 | } 30 | 31 | func execute(request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask? { 32 | return parentSessionManager.execute(request: request, completionHandler: completionHandler) 33 | } 34 | 35 | } 36 | 37 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/Extensions/ClientRequestBuilder+Combine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClientRequestBuilder+RX.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/20/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | //extension ClientRequestBuilder { 13 | // 14 | // struct CombineExtension { 15 | // 16 | // let builder: ClientRequestBuilder 17 | // 18 | // func execute(_ method: HTTPMethod = .get) -> AnyPublisher { 19 | // send(method) 20 | // .decode(type: ResultType.self, decoder: JSONDecoder()) 21 | // .mapError { (error) -> APIError in 22 | // error as? APIError ?? .application 23 | // } 24 | // .eraseToAnyPublisher() 25 | // } 26 | // 27 | // func execute(_ method: HTTPMethod = .get) -> AnyPublisher { 28 | // let publisher = PassthroughSubject() 29 | // let task = builder 30 | // .method(method) 31 | // .send { (data, _, error) in 32 | // if let data = data { 33 | // publisher.send(data) 34 | // } else { 35 | // publisher.send(completion: .failure(error as? APIError ?? .unexpected)) 36 | // } 37 | // } 38 | // task.resume() 39 | // return publisher.eraseToAnyPublisher() 40 | // } 41 | // 42 | // } 43 | // 44 | // var combine: CombineExtension { 45 | // CombineExtension(builder: self) 46 | // } 47 | // 48 | //} 49 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/Extensions/ClientRequestBuilder+Result.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClientRequestBuilder+Result.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/20/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | extension ClientRequestBuilder { 13 | 14 | // struct ResultExtension { 15 | // 16 | // let builder: ClientRequestBuilder 17 | // 18 | // func execute(_ method: HTTPMethod, completionHandler: @escaping (_ result: Result) -> Void) -> Void { 19 | // builder 20 | // .method(method) 21 | // .execute { (data, _, error) in 22 | // if let data = data, let result = try? JSONDecoder().decode(ResultType.self, from: data) { 23 | // completionHandler(.success(result)) 24 | // } else { 25 | // completionHandler(.failure(error as? APIError ?? .application)) 26 | // } 27 | // } 28 | // .resume() 29 | // } 30 | // 31 | // func execute(_ method: HTTPMethod, completionHandler: @escaping (_ result: Result) -> Void) -> Void { 32 | // builder 33 | // .method(method) 34 | // .send { (data, _, error) in 35 | // if let data = data { 36 | // completionHandler(.success(data)) 37 | // } else { 38 | // completionHandler(.failure(error as? APIError ?? .application)) 39 | // } 40 | // } 41 | // .resume() 42 | // } 43 | // 44 | // } 45 | // 46 | // var result: ResultExtension { 47 | // ResultExtension(builder: self) 48 | // } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/Extensions/ClientRequestBuilder+RxSwift.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClientRequestBuilder+RX.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/20/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxSwift 11 | 12 | extension ClientRequestBuilder { 13 | 14 | func get() -> Single { 15 | execute(.get) 16 | } 17 | 18 | func post() -> Single { 19 | execute(.post) 20 | } 21 | 22 | func put() -> Single { 23 | execute(.put) 24 | } 25 | 26 | func create() -> Single { 27 | execute(.create) 28 | } 29 | 30 | func delete() -> Single { 31 | execute(.delete) 32 | } 33 | 34 | func execute(_ method: HTTPMethod = .get) -> Single { 35 | execute(method) 36 | .map { (data, response, error) -> Data in 37 | if let error = error { 38 | throw error //as? APIError ?? .unexpected 39 | } else { 40 | return data ?? Data() 41 | } 42 | } 43 | } 44 | 45 | func execute(_ method: HTTPMethod = .get) -> Single<(Data?, URLResponse?, Error?)> { 46 | Single.create { (single) -> Disposable in 47 | let task = self 48 | .method(method) 49 | .execute { (data, response, error) in 50 | single(.success((data, response, error))) 51 | } 52 | task?.resume() 53 | return Disposables.create { task?.cancel() } 54 | } 55 | } 56 | 57 | // struct RxExtension { 58 | // 59 | // let builder: ClientRequestBuilder 60 | // 61 | // func send(_ method: HTTPMethod = .get) -> Single { 62 | // Single.create { (single) -> Disposable in 63 | // let task = builder 64 | // .method(method) 65 | // .send { (data, _, error) in 66 | // if let data = data { 67 | // single(.success(data)) 68 | // } else { 69 | // single(.failure(error as? APIError ?? .unexpected)) 70 | // } 71 | // } 72 | // task.resume() 73 | // return Disposables.create { task.cancel() } 74 | // } 75 | // } 76 | // 77 | // } 78 | // 79 | // var rx: RxExtension { 80 | // RxExtension(builder: self) 81 | // } 82 | 83 | } 84 | 85 | 86 | public extension Single where Element == Data, Trait == SingleTrait { 87 | func decode(type: Item.Type, decoder: Decoder) -> Single { 88 | map { try decoder.decode(type, from: $0) } 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/Inteceptors/MockDelayInterceptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockDelayInterceptor.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/13/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class MockDelayInterceptor: ClientSessionManagerInterceptor { 12 | 13 | var parentSessionManager: ClientSessionManager! 14 | 15 | static var delay: Double = 0.2 16 | 17 | init(delay: Double = 0.2) { 18 | MockDelayInterceptor.delay = delay 19 | } 20 | 21 | func execute(request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask? { 22 | let interceptor: (Data?, URLResponse?, Error?) -> Void = { (data, response, error) in 23 | if MockDelayInterceptor.delay > 0 { 24 | DispatchQueue.main.asyncAfter(deadline: .now() + MockDelayInterceptor.delay) { 25 | completionHandler(data, response, error) 26 | } 27 | } else { 28 | completionHandler(data, response, error) 29 | } 30 | } 31 | return parentSessionManager.execute(request: request, completionHandler: interceptor) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/Inteceptors/MockSessionManagerWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClientSessionManager.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/19/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class MockSessionManagerWrapper: ClientSessionManagerWrapper { 12 | 13 | var wrappedSessionManager: ClientSessionManager! 14 | var session = URLSession.mock 15 | 16 | func request(forURL url: URL?) -> URLRequest { 17 | wrappedSessionManager.request(forURL: url) 18 | } 19 | 20 | func execute(request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask? { 21 | if let path = request.url?.path, MockURLProtocol.responses[path] != nil { 22 | return session.dataTask(with: request, completionHandler: completionHandler) 23 | } 24 | return wrappedSessionManager.execute(request: request, completionHandler: completionHandler) 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/Inteceptors/SSOAuthenticationInterceptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Interceptors.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/13/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxSwift 11 | 12 | class SSOAuthenticationInterceptor: ClientSessionManagerInterceptor { 13 | 14 | var parentSessionManager: ClientSessionManager! 15 | 16 | var token: String? 17 | 18 | init() {} 19 | 20 | func request(forURL url: URL?) -> URLRequest { 21 | var request = parentSessionManager.request(forURL: url) 22 | if let token = token { 23 | request.setValue(token, forHTTPHeaderField: "Authorization") 24 | print("Added authentication headers") 25 | } 26 | return request 27 | } 28 | 29 | } 30 | 31 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/Inteceptors/SessionLoggingInterceptorr.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Interceptors.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/13/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxSwift 11 | 12 | 13 | class SessionLoggingInterceptor: ClientSessionManagerInterceptor { 14 | 15 | var parentSessionManager: ClientSessionManager! 16 | 17 | init() {} 18 | 19 | func execute(request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask? { 20 | print("REQ: \(request)") 21 | let interceptor: (Data?, URLResponse?, Error?) -> Void = { (data, response, error) in 22 | let status: Int = (response as? HTTPURLResponse)?.statusCode ?? 999 23 | print("\(status): \(request)") 24 | completionHandler(data, response, error) 25 | } 26 | return parentSessionManager.execute(request: request, completionHandler: interceptor) 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/Inteceptors/StandardHeadersInterceptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Interceptors.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/13/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxSwift 11 | 12 | class StandardHeadersInterceptor: ClientSessionManagerInterceptor { 13 | 14 | var parentSessionManager: ClientSessionManager! 15 | 16 | init() {} 17 | 18 | func request(forURL url: URL?) -> URLRequest { 19 | var request = parentSessionManager.request(forURL: url) 20 | request.setValue("something", forHTTPHeaderField: "header") 21 | print("Added standard headers") 22 | return request 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/Inteceptors/StatusErrorMappingInterceptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Interceptors.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/13/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxSwift 11 | 12 | class StatusErrorMappingInterceptor: ClientSessionManagerInterceptor { 13 | 14 | var parentSessionManager: ClientSessionManager! 15 | 16 | init() {} 17 | 18 | func execute(request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask? { 19 | let interceptor: (Data?, URLResponse?, Error?) -> Void = { (data, response, error) in 20 | let status: Int = (response as? HTTPURLResponse)?.statusCode ?? 999 21 | switch status { 22 | case 404: 23 | completionHandler(data, response, APIError.unknown) 24 | case 500: 25 | completionHandler(data, response, APIError.unknown) 26 | default: 27 | completionHandler(data, response, error) 28 | } 29 | } 30 | return parentSessionManager.execute(request: request, completionHandler: interceptor) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Networking/_Networking+Injection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Netowrking+Injection.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/17/21. 6 | // 7 | 8 | import UIKit 9 | import Factory 10 | 11 | extension Container { 12 | enum APIMode: String { 13 | case api 14 | case mock 15 | case test 16 | } 17 | 18 | #if MOCK 19 | static var apiMode = APIMode.mock 20 | #else 21 | static var apiMode = APIMode.api 22 | #endif 23 | } 24 | 25 | extension Container { 26 | 27 | static let clientSessionManager = Factory(scope: .singleton) { 28 | switch apiMode { 29 | case .api: 30 | return clientSessionManagerAPI() 31 | case .mock: 32 | return clientSessionManagerMock() 33 | case .test: 34 | return clientSessionManagerTest() 35 | } 36 | } 37 | 38 | private static let urlSession = Factory(scope: .singleton) { 39 | let configuration = URLSessionConfiguration.ephemeral 40 | configuration.protocolClasses = [MockURLProtocol.self] // add mock protocol handler 41 | return URLSession(configuration: configuration) 42 | } 43 | 44 | private static let clientSessionManagerAPI = Factory { 45 | BaseSessionManager(base: "https://randomuser.me/api", session: urlSession()) 46 | .interceptor(StatusErrorMappingInterceptor()) 47 | .interceptor(StandardHeadersInterceptor()) 48 | .interceptor(SSOAuthenticationInterceptor()) 49 | .interceptor(SessionLoggingInterceptor()) 50 | } 51 | 52 | private static let clientSessionManagerMock = Factory { 53 | MockURLProtocol.set(forPath: "/api/portraits/men/11.jpg", status: 200, data: UIImage(named: "User-JQ")?.pngData()) 54 | MockURLProtocol.set(forPath: "/api/portraits/med/men/11.jpg", status: 200, data: UIImage(named: "User-JQ")?.pngData()) 55 | MockURLProtocol.set(forPath: "User-JQ", status: 200, data: UIImage(named: "User-JQ")?.pngData()) 56 | MockURLProtocol.set(forPath: "User-TS", status: 404) 57 | MockURLProtocol.set(forPath: "/test", status: 200, json: "{\"name\":\"Michael\":}") 58 | MockURLProtocol.setupDefaultJSONBundleHandler() 59 | 60 | return BaseSessionManager(base: "/", session: urlSession()) 61 | .interceptor(StatusErrorMappingInterceptor()) 62 | .interceptor(StandardHeadersInterceptor()) 63 | .interceptor(SSOAuthenticationInterceptor()) 64 | .interceptor(SessionLoggingInterceptor()) 65 | .interceptor(MockDelayInterceptor(delay: 0.3)) 66 | } 67 | 68 | private static let clientSessionManagerTest = Factory { 69 | BaseSessionManager(base: "/", session: urlSession()) 70 | .interceptor(StatusErrorMappingInterceptor()) 71 | .interceptor(StandardHeadersInterceptor()) 72 | .interceptor(SSOAuthenticationInterceptor()) 73 | .interceptor(SessionLoggingInterceptor()) 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Styles/ButtonView+Styles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ButtonView+Styles.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 9/8/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | 11 | struct StyleButtonFilled: BuilderStyle { 12 | public func apply(to view: ButtonView.Base) { 13 | With(view) 14 | .cornerRadius(8) 15 | .color(.white) 16 | .backgroundColor(.blue, for: .normal) 17 | .backgroundColor(.blue.darker(), for: .highlighted) 18 | .backgroundColor(.gray, for: .disabled) 19 | .padding(10) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Styles/LabelView+Styles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LabelView+Styles.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 9/8/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | 11 | extension UIColor { 12 | static let standardAccentColor = UIColor(red: 1/255, green: 50/255, blue: 159/255, alpha: 1) 13 | } 14 | 15 | struct StyleLabelAccentTitle: BuilderStyle { 16 | public func apply(to view: LabelView.Base) { 17 | With(view) 18 | .font(.footnote) 19 | .color(.standardAccentColor) 20 | .with { 21 | $0.text = $0.text?.uppercased() 22 | } 23 | } 24 | } 25 | 26 | struct StyleLabelFootnote: BuilderStyle { 27 | public func apply(to view: LabelView.Base) { 28 | With(view) 29 | .font(.footnote) 30 | .color(.secondaryLabel) 31 | .numberOfLines(0) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Shared/Styles/TextField+Styles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextField+Styles.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 9/8/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | 11 | struct StyleStandardMetaTextField: BuilderStyle { 12 | public func apply(to view: MetaTextField.Base) { 13 | With(view) 14 | .autocorrectionType(.no) 15 | .set(keyPath: \.clearButtonMode, value: .whileEditing) 16 | .height(34) 17 | .tintColor(.red) 18 | .with { 19 | $0.bottomLineColor = .standardAccentColor 20 | $0.selectedBottomLineColor = .standardAccentColor 21 | } 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Test/GeneralTestView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralTestView.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/6/22. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import RxSwift 11 | import Factory 12 | 13 | class GeneralTestViewController: UIViewController { 14 | 15 | let disposeBag = DisposeBag() 16 | 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | view.backgroundColor = .systemBackground 20 | view.embed(content()) 21 | 22 | print(UITraitCollection.current.userInterfaceStyle == .light ? "Light Mode" : "Dark Mode") 23 | 24 | } 25 | 26 | func content() -> View { 27 | VStackView { 28 | VerticalScrollView { 29 | VStackView { 30 | ContainerView { 31 | LabelView("This is a test!") 32 | .backgroundColor(.red) 33 | .color(.white) 34 | // .margins(20) // only within container 35 | .padding(10) 36 | } 37 | .padding(20) 38 | .backgroundColor(.yellow) 39 | .height(100) 40 | 41 | SpacerView() 42 | } 43 | .padding(20) 44 | } 45 | ContainerView { 46 | MyButtonView() 47 | .safeArea(true) 48 | } 49 | } 50 | } 51 | 52 | } 53 | 54 | struct MyButtonView: ViewBuilder { 55 | var body: View { 56 | ViewModifier(DLSInsetFilledActionButton("Submit")) 57 | .height(58) 58 | .onTap { context in 59 | print("tapped") 60 | } 61 | 62 | } 63 | } 64 | 65 | 66 | class DLSInsetFilledActionButton: UIButton { 67 | 68 | var backgroundView: UIView! 69 | 70 | public init(_ title: String = "") { 71 | super.init(frame: .zero) 72 | self.setTitle(title, for: .normal) 73 | setupCommon() 74 | } 75 | 76 | public override init(frame: CGRect) { 77 | super.init(frame: frame) 78 | setupCommon() 79 | } 80 | 81 | required init?(coder: NSCoder) { 82 | super.init(coder: coder) 83 | setupCommon() 84 | } 85 | 86 | func setupCommon() { 87 | backgroundView = BuilderDemo.with(UIView()) { 88 | $0.translatesAutoresizingMaskIntoConstraints = false 89 | $0.backgroundColor = .red 90 | $0.isUserInteractionEnabled = false 91 | $0.layer.cornerRadius = 2.0 92 | } 93 | insertConstrainedSubview(backgroundView, at: 0, position: .fill, padding: UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)) 94 | titleLabel?.font = .preferredFont(forTextStyle: .headline) 95 | backgroundColor = .systemBackground 96 | setTitleColor(.systemBackground, for: .normal) 97 | } 98 | 99 | override var isHighlighted: Bool { 100 | didSet { 101 | UIView.animate(withDuration: 0.25, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction], animations: { 102 | self.backgroundView.backgroundColor = self.isHighlighted ? .red.darker() : .red 103 | }, completion: nil) 104 | } 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Test/TestViews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestViews.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/26/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | 11 | struct SomeViewBuilder2: ViewBuilder { 12 | var body: View { 13 | LabelView("This is some text!") 14 | .hidden(false) 15 | .font(.preferredFont(forTextStyle: .largeTitle)) 16 | .with { 17 | $0.heightAnchor.constraint(equalToConstant: 200).isActive = true 18 | $0.widthAnchor.constraint(equalToConstant: 350).isActive = true 19 | } 20 | } 21 | } 22 | 23 | struct AnotherViewBuilder2: ViewBuilder { 24 | var body: View { 25 | SomeViewBuilder1() 26 | .hidden(true) 27 | } 28 | } 29 | 30 | struct AnotherViewBuilder3: ViewBuilder { 31 | var body: View { 32 | UITextView() 33 | .hidden(true) 34 | .with { 35 | $0.heightAnchor.constraint(equalToConstant: 200).isActive = true 36 | $0.widthAnchor.constraint(equalToConstant: 350).isActive = true 37 | } 38 | } 39 | } 40 | 41 | struct SomeViewBuilder1: ViewBuilder { 42 | var body: View { 43 | LabelView("This is VB1 Text!") 44 | } 45 | } 46 | 47 | struct AnotherViewBuilder1: ViewBuilder { 48 | var body: View { 49 | SomeViewBuilder1() 50 | } 51 | } 52 | 53 | struct AnotherViewBuilder4: ViewBuilder { 54 | var body: View { 55 | UITextView() 56 | } 57 | } 58 | 59 | struct AnotherViewBuilder5: ViewBuilder { 60 | var body: View { 61 | SomeViewBuilder1() 62 | .hidden(true) 63 | .with { 64 | $0.heightAnchor.constraint(equalToConstant: 200).isActive = true 65 | $0.widthAnchor.constraint(equalToConstant: 350).isActive = true 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // BuilderDemo 4 | // 5 | // Created by Michael Long on 6/18/22. 6 | // 7 | 8 | import UIKit 9 | 10 | class ViewController: UIViewController { 11 | 12 | override func viewDidLoad() { 13 | super.viewDidLoad() 14 | // Do any additional setup after loading the view. 15 | } 16 | 17 | 18 | } 19 | 20 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Views/DLSCardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DLSCardView.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/22/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | 11 | struct DLSCardView: ViewBuilder { 12 | 13 | let content: () -> ViewConvertable 14 | 15 | init(@ViewResultBuilder _ content: @escaping () -> ViewConvertable) { 16 | self.content = content 17 | } 18 | 19 | var body: View { 20 | ContainerView { 21 | ContainerView { 22 | content() 23 | } 24 | .roundedCorners(radius: 16, corners: [.layerMinXMinYCorner]) 25 | .clipsToBounds(true) 26 | } 27 | .backgroundColor(.secondarySystemGroupedBackground) 28 | .roundedCorners(radius: 16, corners: [.layerMinXMinYCorner]) 29 | .shadow(color: .black, radius: 2, opacity: 0.25, offset: CGSize(width: 0, height: 2)) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Views/MetaTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetaTextField.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 12/7/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import RxSwift 11 | import RxRelay 12 | 13 | public struct MetaTextField: ModifiableView { 14 | 15 | public let modifiableView = Modified(BuilderInternalTextField()) { 16 | $0.setContentCompressionResistancePriority(.required, for: .vertical) 17 | $0.font = UIFont.preferredFont(forTextStyle: .body) 18 | $0.bottomLineColor = .gray 19 | $0.selectedBottomLineColor = .blue 20 | $0.errorColor = .red 21 | } 22 | 23 | // lifecycle 24 | public init() { 25 | // with later binding 26 | } 27 | 28 | public init(_ text: String?) { 29 | modifiableView.text = text 30 | } 31 | 32 | public init(_ binding: Binding) where Binding.T == String? { 33 | text(bind: binding) 34 | } 35 | 36 | public init(_ binding: Binding) where Binding.T == String { 37 | text(bidirectionalBind: binding) 38 | } 39 | 40 | public init(_ binding: Binding) where Binding.T == String? { 41 | text(bidirectionalBind: binding) 42 | } 43 | 44 | } 45 | 46 | extension ModifiableView where Base: BuilderInternalTextField { 47 | 48 | @discardableResult 49 | func error(bind binding: Binding) -> ViewModifier where Binding.T == String? { 50 | ViewModifier(modifiableView) { 51 | binding.asObservable() 52 | .skip(1) 53 | .observe(on: ConcurrentMainScheduler.instance) 54 | .map { $0 ?? "" } 55 | .bind(to: $0.errorText) 56 | .disposed(by: $0.rxDisposeBag) 57 | } 58 | } 59 | 60 | @discardableResult 61 | func maxWidth(_ width: Int) -> ViewModifier { 62 | ViewModifier(modifiableView) { $0.behavior = MaxWidthTextFieldBehavior(width) } 63 | } 64 | 65 | @discardableResult 66 | func mask(_ mask: String) -> ViewModifier { 67 | ViewModifier(modifiableView) { $0.behavior = MaskedTextFieldBehavior(mask) } 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Views/StandardEmptyPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StandardEmptyPage.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 3/1/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | 13 | struct StandardEmptyPage: ViewBuilder { 14 | 15 | let message: String 16 | 17 | var body: View { 18 | return VerticalScrollView { 19 | VStackView { 20 | LabelView(message) 21 | .color(.systemGray) 22 | SpacerView() 23 | } 24 | .padding(16) 25 | } 26 | .backgroundColor(.systemBackground) 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Views/StandardErrorPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StandardErrorPage.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 3/1/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | 13 | struct StandardErrorPage: ViewBuilder { 14 | 15 | let error: String 16 | 17 | var body: View { 18 | return VerticalScrollView { 19 | VStackView { 20 | LabelView(error) 21 | .color(.red) 22 | SpacerView() 23 | } 24 | .padding(16) 25 | } 26 | .backgroundColor(.systemBackground) 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/Views/StandardLoadingPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StandardLoadingPage.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 3/1/21. 6 | // 7 | 8 | import UIKit 9 | import Builder 10 | import Factory 11 | import RxSwift 12 | 13 | struct StandardLoadingPage: ViewBuilder { 14 | 15 | var body: View { 16 | return VerticalScrollView { 17 | VStackView { 18 | With(UIActivityIndicatorView()) { 19 | $0.color = .systemGray 20 | $0.startAnimating() 21 | } 22 | SpacerView() 23 | } 24 | .padding(16) 25 | } 26 | .backgroundColor(.systemBackground) 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/_Main+Injection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Main+Injection.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 1/17/21. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | 11 | extension Container { 12 | static let mainViewModel = Factory(scope: .shared) { 13 | MainViewModel() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/json/get.json: -------------------------------------------------------------------------------- 1 | {"results":[{"gender":"M","id":{"name":"21","value":"21"},"email":"tomswift@swiftenterprises.com","phone":"402-555-9999","name":{"title":"Mr.","first":"Tom","last":"Swift"},"nat":"US"},{"gender":"M","phone":"303-555-8888","nat":"US","id":{"name":"21","value":"21"},"picture":{"large":"User-JQ","thumbnail":"User-JQ","medium":"User-JQ"},"email":"jquest@quest.com","name":{"title":"Mr.","first":"Jonny","last":"Quest"}}]} 2 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemo/json/get_users.json: -------------------------------------------------------------------------------- 1 | {"results":[{"gender":"M","id":{"name":"21","value":"21"},"email":"tomswift@swiftenterprises.com","phone":"402-555-9999","name":{"title":"Mr.","first":"Tom","last":"Swift"},"nat":"US"},{"gender":"M","phone":"303-555-8888","nat":"US","id":{"name":"21","value":"21"},"picture":{"large":"User-JQ","thumbnail":"User-JQ","medium":"User-JQ"},"email":"jquest@quest.com","name":{"title":"Mr.","first":"Jonny","last":"Quest"}}]} 2 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemoTests/BuilderDemoTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BuilderDemoTests.swift 3 | // BuilderDemoTests 4 | // 5 | // Created by Michael Long on 6/18/22. 6 | // 7 | 8 | import XCTest 9 | @testable import BuilderDemo 10 | 11 | class BuilderDemoTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | // Any test you write for XCTest can be annotated as throws and async. 25 | // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. 26 | // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. 27 | } 28 | 29 | func testPerformanceExample() throws { 30 | // This is an example of a performance test case. 31 | self.measure { 32 | // Put the code you want to measure the time of here. 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemoTests/Factory+XCTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Factory+XCTest.swift 3 | // BuilderTests 4 | // 5 | // Created by Michael Long on 2/28/21. 6 | // 7 | 8 | import XCTest 9 | import Factory 10 | import RxSwift 11 | import RxCocoa 12 | 13 | @testable import BuilderDemo 14 | 15 | extension Container { 16 | 17 | static func resetUnitTestRegistrations() { 18 | 19 | userServiceType.register { MockUserService() } 20 | userImageCache.register { UserImageCache() } // use our own and not global 21 | 22 | MockDelayInterceptor.delay = 0.0 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemoTests/TestExrtensionsSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainViewModelSpec.swift 3 | // BuilderTests 4 | // 5 | // Created by Michael Long on 2/22/21. 6 | // 7 | 8 | import XCTest 9 | import Factory 10 | import RxSwift 11 | import RxCocoa 12 | 13 | @testable import BuilderDemo 14 | 15 | class TestExtensionsSpec: XCTestCase { 16 | 17 | func testTestFunctions() throws { 18 | let relay = BehaviorRelay(value: "initial") 19 | 20 | DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) { 21 | relay.accept("updated") 22 | } 23 | 24 | test("Test relay current value", value: relay.asObservable()) { 25 | $0 == "initial" 26 | } 27 | 28 | test("Test relay eventual value", value: relay.asObservable()) { 29 | $0 == "updated" 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemoTests/UserImageCacheSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserImageCacheSpec.swift 3 | // BuilderTests 4 | // 5 | // Created by Michael Long on 2/22/21. 6 | // 7 | 8 | import XCTest 9 | import Factory 10 | import RxSwift 11 | import RxCocoa 12 | 13 | @testable import BuilderDemo 14 | 15 | class UserImageCacheSpec: XCTestCase { 16 | 17 | override func setUp() { 18 | Container.Registrations.push() 19 | Container.resetUnitTestRegistrations() 20 | } 21 | 22 | override func tearDown() { 23 | Container.Registrations.pop() 24 | } 25 | 26 | func testImageThumbnails() throws { 27 | let cache = UserImageCache() 28 | 29 | let image1 = cache.thumbnail(forUser: User.mockJQ).asObservable() 30 | test("Test has thumbnail for user", value: image1) { 31 | $0 == UIImage(named: "User-JQ") 32 | } 33 | 34 | let image2 = cache.thumbnail(forUser: User.mockTS).asObservable() 35 | test("Test no image for user", value: image2) { 36 | $0 == nil 37 | } 38 | } 39 | 40 | func testImagePlaceholders() throws { 41 | let cache = UserImageCache() 42 | 43 | let image1 = cache.thumbnailOrPlaceholder(forUser: User.mockJQ).asObservable() 44 | test("Test has thumbnail for user", value: image1) { 45 | $0 == UIImage(named: "User-JQ") 46 | } 47 | 48 | let image2 = cache.thumbnailOrPlaceholder(forUser: User.mockTS).asObservable() 49 | test("Test no image for user", value: image2) { 50 | $0 == UIImage(named: "User-Unknown") 51 | } 52 | } 53 | 54 | func testThunbnailError() throws { 55 | Container.userServiceType.register { MockErrorUserService() as UserServiceType } 56 | 57 | let cache = UserImageCache() 58 | let image1 = cache.thumbnail(forUser: User.mockJQ).asObservable() 59 | 60 | test("Test receiving nothing on error", error: image1) { 61 | $0 is APIError 62 | } 63 | 64 | let image2 = cache.thumbnailOrPlaceholder(forUser: User.mockJQ).asObservable() 65 | test("Test receiving placeholder image on error", value: image2) { 66 | $0 == UIImage(named: "User-Unknown") 67 | } 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemoTests/XCTTest+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTTest+Extensions.swift 3 | // BuilderTests 4 | // 5 | // Created by Michael Long on 2/22/21. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | import RxSwift 11 | 12 | 13 | extension XCTestCase { 14 | 15 | static var defaultDelay: Double = 2.0 16 | 17 | func test(_ description: String, delay: Double = defaultDelay, handler: (_ done: @escaping () -> Void) -> Void) { 18 | let expectation = XCTestExpectation(description: description) 19 | let done = { () in expectation.fulfill() } 20 | handler(done) 21 | wait(for: [expectation], timeout: delay) 22 | } 23 | 24 | } 25 | 26 | extension XCTestCase { 27 | 28 | func test(_ description: String, value o: Observable, delay: Double = defaultDelay, test: @escaping (_ value: T) -> Bool) { 29 | let expectation = XCTestExpectation(description: description) 30 | var success = false 31 | let disposable = o 32 | .subscribe(onNext: { (value) in 33 | guard success == false else { return } 34 | if test(value) { 35 | success = true 36 | expectation.fulfill() 37 | } 38 | }, onCompleted: { 39 | if success == false { 40 | XCTFail(description) 41 | expectation.fulfill() 42 | } 43 | }) 44 | wait(for: [expectation], timeout: delay) 45 | disposable.dispose() 46 | } 47 | 48 | func test(_ description: String, completed o: Observable, delay: Double = defaultDelay) { 49 | let expectation = XCTestExpectation(description: description) 50 | let disposable = o 51 | .subscribe(onError: { (e) in 52 | XCTFail(description) 53 | expectation.fulfill() 54 | }, onCompleted: { 55 | expectation.fulfill() 56 | }) 57 | wait(for: [expectation], timeout: delay) 58 | disposable.dispose() 59 | } 60 | 61 | func test(_ description: String, error o: Observable, delay: Double = defaultDelay, test: @escaping (_ error: Error) -> Bool) { 62 | let expectation = XCTestExpectation(description: description) 63 | let disposable = o 64 | .subscribe(onError: { (e) in 65 | if !test(e) { 66 | XCTFail(description) 67 | } 68 | expectation.fulfill() 69 | }, onCompleted: { 70 | XCTFail(description) 71 | expectation.fulfill() 72 | }) 73 | wait(for: [expectation], timeout: delay) 74 | disposable.dispose() 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemoUITests/BuilderDemoUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BuilderDemoUITests.swift 3 | // BuilderDemoUITests 4 | // 5 | // Created by Michael Long on 6/18/22. 6 | // 7 | 8 | import XCTest 9 | 10 | class BuilderDemoUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use XCTAssert and related functions to verify your tests produce the correct results. 31 | } 32 | 33 | func testLaunchPerformance() throws { 34 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 35 | // This measures how long it takes to launch your application. 36 | measure(metrics: [XCTApplicationLaunchMetric()]) { 37 | XCUIApplication().launch() 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /BuilderDemo/BuilderDemoUITests/BuilderDemoUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BuilderDemoUITestsLaunchTests.swift 3 | // BuilderDemoUITests 4 | // 5 | // Created by Michael Long on 6/18/22. 6 | // 7 | 8 | import XCTest 9 | 10 | class BuilderDemoUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | func testLaunch() throws { 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Insert steps here to perform after app launch but before taking a screenshot, 25 | // such as logging into a test account or navigating somewhere in the app 26 | 27 | let attachment = XCTAttachment(screenshot: app.screenshot()) 28 | attachment.name = "Launch Screen" 29 | attachment.lifetime = .keepAlways 30 | add(attachment) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Michael Long 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "rxswift", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/ReactiveX/RxSwift.git", 7 | "state" : { 8 | "revision" : "b4307ba0b6425c0ba4178e138799946c3da594f8", 9 | "version" : "6.5.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Builder", 8 | platforms: [ 9 | .iOS(.v12) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "Builder", 15 | targets: ["Builder"] 16 | ), 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.5.0"), 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 25 | .target( 26 | name: "Builder", 27 | dependencies: ["RxSwift", .product(name: "RxCocoa", package: "RxSwift")]), 28 | .testTarget( 29 | name: "BuilderTests", 30 | dependencies: ["Builder", "RxSwift", .product(name: "RxCocoa", package: "RxSwift")]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /SampleDetail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmlongco/Builder/2445c003dec35fa06917898629f8d1cd91d99b8f/SampleDetail.png -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Button.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+Button 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/29/19. 6 | // Copyright © 2019 Michael Long. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | import RxCocoa 12 | 13 | 14 | public struct ButtonView: ModifiableView { 15 | 16 | public let modifiableView = Modified(UIButton()) { 17 | $0.setTitleColor(ViewBuilderEnvironment.defaultButtonColor ?? $0.tintColor, for: .normal) 18 | $0.titleLabel?.font = ViewBuilderEnvironment.defaultButtonFont ?? .preferredFont(forTextStyle: .headline) 19 | $0.setContentHuggingPriority(.defaultHigh, for: .horizontal) 20 | $0.setContentCompressionResistancePriority(.required, for: .vertical) 21 | } 22 | 23 | // lifecycle 24 | public init(_ title: String? = nil) { 25 | modifiableView.setTitle(title, for: .normal) 26 | } 27 | 28 | public init(_ title: String? = nil, action: @escaping (_ context: ViewBuilderContext) -> Void) { 29 | modifiableView.setTitle(title, for: .normal) 30 | onTap(action) 31 | } 32 | } 33 | 34 | 35 | // Custom UIImageView modifiers 36 | extension ModifiableView where Base: UIButton { 37 | 38 | @discardableResult 39 | public func alignment(_ alignment: UIControl.ContentHorizontalAlignment) -> ViewModifier { 40 | ViewModifier(modifiableView, keyPath: \.contentHorizontalAlignment, value: alignment) 41 | } 42 | 43 | @discardableResult 44 | public func backgroundColor(_ color: UIColor, for state: UIControl.State) -> ViewModifier { 45 | ViewModifier(modifiableView) { $0.setBackgroundImage(UIImage(color: color), for: state) } 46 | } 47 | 48 | @discardableResult 49 | public func color(_ color: UIColor, for state: UIControl.State = .normal) -> ViewModifier { 50 | ViewModifier(modifiableView) { $0.setTitleColor(color, for: state) } 51 | } 52 | 53 | @discardableResult 54 | public func font(_ font: UIFont?) -> ViewModifier { 55 | ViewModifier(modifiableView) { $0.titleLabel?.font = font } 56 | } 57 | 58 | @discardableResult 59 | public func font(_ style: UIFont.TextStyle) -> ViewModifier { 60 | ViewModifier(modifiableView) { $0.titleLabel?.font = .preferredFont(forTextStyle: style) } 61 | } 62 | 63 | @discardableResult 64 | public func onTap(_ handler: @escaping (_ context: ViewBuilderContext) -> Void) -> ViewModifier { 65 | ViewModifier(modifiableView) { [unowned modifiableView] view in 66 | view.rx.tap 67 | .throttle(.milliseconds(300), latest: false, scheduler: MainScheduler.instance) 68 | .subscribe(onNext: { () in handler(ViewBuilderContext(view: modifiableView)) }) 69 | .disposed(by: view.rxDisposeBag) 70 | } 71 | } 72 | 73 | } 74 | 75 | extension UIButton: ViewBuilderPaddable { 76 | 77 | public func setPadding(_ padding: UIEdgeInsets) { 78 | self.contentEdgeInsets = padding 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Container.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Build+Container.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 9/28/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | 12 | public struct ContainerView: ModifiableView { 13 | 14 | public var modifiableView = Modified(BuilderInternalContainerView(frame: .zero)) { 15 | $0.backgroundColor = .clear 16 | $0.isUserInteractionEnabled = true 17 | } 18 | 19 | public init(_ view: View? = nil) { 20 | modifiableView.views = view 21 | } 22 | 23 | public init(@ViewResultBuilder _ builder: () -> ViewConvertable) { 24 | modifiableView.views = builder() 25 | } 26 | 27 | } 28 | 29 | public typealias DynamicContainerView = ContainerView 30 | 31 | extension DynamicContainerView { 32 | public init(_ binding: Binding, @ViewResultBuilder _ builder: @escaping (_ value: Value) -> ViewConvertable) 33 | where Binding.T == Value { 34 | binding.asObservable() 35 | .subscribe(onNext: { [weak modifiableView] value in 36 | modifiableView?.transition(to: builder(value)) 37 | }) 38 | .disposed(by: modifiableView.rxDisposeBag) 39 | } 40 | 41 | } 42 | 43 | extension ModifiableView where Base: BuilderInternalContainerView { 44 | 45 | @discardableResult 46 | func defaultPosition(_ position: UIView.EmbedPosition) -> ViewModifier { 47 | ViewModifier(modifiableView, keyPath: \.position, value: position) 48 | } 49 | 50 | @discardableResult 51 | func defaultSafeArea(_ safeArea: Bool) -> ViewModifier { 52 | ViewModifier(modifiableView, keyPath: \.safeArea, value: safeArea) 53 | } 54 | 55 | } 56 | 57 | public class BuilderInternalContainerView: UIView, ViewBuilderEventHandling { 58 | 59 | fileprivate var views: ViewConvertable? 60 | fileprivate var padding: UIEdgeInsets = .zero 61 | fileprivate var position: EmbedPosition = .fill 62 | fileprivate var safeArea: Bool = false 63 | 64 | convenience public init(_ view: View?) { 65 | self.init(frame: .zero) 66 | self.views = view 67 | } 68 | 69 | convenience public init(@ViewResultBuilder _ builder: () -> ViewConvertable) { 70 | self.init(frame: .zero) 71 | self.views = builder() 72 | } 73 | 74 | public func transition(to views: ViewConvertable?) { 75 | if superview == nil { 76 | self.views = views 77 | } else if let view = views?.asViews().first { 78 | transition(to: view) 79 | } 80 | } 81 | 82 | override public func didMoveToSuperview() { 83 | embed(views?.asViews() ?? [], padding: padding, safeArea: safeArea) 84 | super.didMoveToSuperview() 85 | } 86 | 87 | override public func didMoveToWindow() { 88 | optionalBuilderAttributes()?.commonDidMoveToWindow(self) 89 | } 90 | 91 | } 92 | 93 | extension BuilderInternalContainerView: ViewBuilderPaddable { 94 | 95 | public func setPadding(_ padding: UIEdgeInsets) { 96 | self.padding = padding 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Controls.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+Controls.swift 3 | // 4 | // Created by Michael Long on 11/25/21. 5 | // Copyright © 2021 Client Resources Inc. All rights reserved. 6 | // 7 | 8 | import UIKit 9 | 10 | extension ModifiableView where Base: UIControl { 11 | 12 | @discardableResult 13 | public func contentHorizontalAlignment(_ alignment: UIControl.ContentHorizontalAlignment) -> ViewModifier { 14 | ViewModifier(modifiableView, keyPath: \.contentHorizontalAlignment, value: alignment) 15 | } 16 | 17 | @discardableResult 18 | public func contentVerticalAlignment(_ alignment: UIControl.ContentVerticalAlignment) -> ViewModifier { 19 | ViewModifier(modifiableView, keyPath: \.contentVerticalAlignment, value: alignment) 20 | } 21 | 22 | @discardableResult 23 | public func enabled(_ enabled: Bool) -> ViewModifier { 24 | ViewModifier(modifiableView, keyPath: \.isEnabled, value: enabled) 25 | } 26 | 27 | @discardableResult 28 | public func highlighted(_ highlighted: Bool) -> ViewModifier { 29 | ViewModifier(modifiableView, keyPath: \.isEnabled, value: highlighted) 30 | } 31 | 32 | @discardableResult 33 | public func selected(_ selected: Bool) -> ViewModifier { 34 | ViewModifier(modifiableView, keyPath: \.isEnabled, value: selected) 35 | } 36 | 37 | } 38 | 39 | extension ModifiableView where Base: UIControl { 40 | 41 | @discardableResult 42 | public func enabled(bind binding: Binding) -> ViewModifier where Binding.T == Bool { 43 | ViewModifier(modifiableView, binding: binding) { $0.isEnabled = $1 } 44 | } 45 | 46 | @discardableResult 47 | public func highlighted(bind binding: Binding) -> ViewModifier where Binding.T == Bool { 48 | ViewModifier(modifiableView, binding: binding) { $0.isHighlighted = $1 } 49 | } 50 | 51 | @discardableResult 52 | public func selected(bind binding: Binding) -> ViewModifier where Binding.T == Bool { 53 | ViewModifier(modifiableView, binding: binding) { $0.isSelected = $1 } 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Divider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+Divider.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 11/9/21. 6 | // 7 | 8 | import UIKit 9 | 10 | // Custom builder fot UILabel 11 | public struct DividerView: ModifiableView { 12 | 13 | public let modifiableView = Modified(BuilderInternalDividerView(frame: .zero)) { 14 | let subview = UIView(frame: .zero) 15 | $0.addSubview(subview) 16 | subview.translatesAutoresizingMaskIntoConstraints = false 17 | if #available(iOS 13, *) { 18 | subview.backgroundColor = ViewBuilderEnvironment.defaultSeparatorColor ?? UIColor.secondaryLabel 19 | } else { 20 | subview.backgroundColor = ViewBuilderEnvironment.defaultSeparatorColor ?? UIColor.black 21 | } 22 | subview.topAnchor.constraint(equalTo: $0.topAnchor, constant: 4.0).isActive = true 23 | subview.leftAnchor.constraint(equalTo: $0.leftAnchor).isActive = true 24 | subview.rightAnchor.constraint(equalTo: $0.rightAnchor).isActive = true 25 | subview.heightAnchor.constraint(equalToConstant: 0.5).isActive = true 26 | subview.bottomAnchor.constraint(equalTo: $0.bottomAnchor, constant: -4.5).isActive = true 27 | $0.backgroundColor = .clear 28 | } 29 | 30 | // lifecycle 31 | public init() {} 32 | 33 | } 34 | 35 | extension ModifiableView where Base: BuilderInternalDividerView { 36 | 37 | @discardableResult 38 | public func color(_ color: UIColor?) -> ViewModifier { 39 | ViewModifier(modifiableView) { $0.subviews.first?.backgroundColor = color } 40 | } 41 | 42 | } 43 | 44 | public class BuilderInternalDividerView: UIView {} 45 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Dynamic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+ForEach.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 9/30/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | 12 | public protocol AnyIndexableViewBuilder: ViewConvertable { 13 | var count: Int { get } 14 | var updated: Observable? { get } 15 | func view(at index: Int) -> View? 16 | } 17 | 18 | public struct StaticViewBuilder: AnyIndexableViewBuilder { 19 | 20 | private var views: [View] 21 | 22 | public init(@ViewResultBuilder _ views: () -> ViewConvertable) { 23 | self.views = views().asViews() 24 | } 25 | 26 | public var count: Int { views.count } 27 | public var updated: Observable? 28 | 29 | public func view(at index: Int) -> View? { 30 | guard views.indices.contains(index) else { return nil } 31 | return views[index] 32 | } 33 | 34 | public func asViews() -> [View] { 35 | views 36 | } 37 | 38 | } 39 | 40 | public class DynamicItemViewBuilder: AnyIndexableViewBuilder { 41 | 42 | public var items: [Item] { 43 | didSet { 44 | updatePublisher.onNext(()) 45 | } 46 | } 47 | 48 | public var count: Int { items.count } 49 | public var updated: Observable? { updatePublisher } 50 | 51 | private let updatePublisher = PublishSubject() 52 | private let builder: (_ item: Item) -> View? 53 | 54 | public init(_ items: [Item]?, builder: @escaping (_ item: Item) -> View?) { 55 | self.items = items ?? [] 56 | self.builder = builder 57 | } 58 | 59 | public func item(at index: Int) -> Item? { 60 | guard items.indices.contains(index) else { return nil } 61 | return items[index] 62 | } 63 | 64 | public func view(at index: Int) -> View? { 65 | guard let item = item(at: index) else { return nil } 66 | return builder(item) 67 | } 68 | 69 | public func asViews() -> [View] { 70 | return items.compactMap { self.builder($0) } 71 | } 72 | 73 | } 74 | 75 | public class DynamicObservableViewBuilder: AnyIndexableViewBuilder { 76 | 77 | public var count: Int { view == nil ? 0 : 1 } 78 | public var updated: Observable? 79 | 80 | private var view: View? 81 | private var disposeBag = DisposeBag() 82 | 83 | public init(_ observable: Observable, builder: @escaping (_ value: Value) -> View) { 84 | self.updated = observable 85 | .do(onNext: { [weak self] value in 86 | self?.view = builder(value) 87 | }) 88 | .map { _ in () } 89 | } 90 | 91 | public func view(at index: Int) -> View? { 92 | guard index == 0 else { return nil } 93 | return view 94 | } 95 | 96 | public func asViews() -> [View] { 97 | guard let view = view else { return [] } 98 | return [view] 99 | } 100 | 101 | } 102 | 103 | public class DynamicValueViewBuilder: AnyIndexableViewBuilder { 104 | 105 | public var value: Value { 106 | didSet { 107 | updatePublisher.onNext(()) 108 | } 109 | } 110 | 111 | public var count: Int = 1 112 | public var updated: Observable? { updatePublisher } 113 | 114 | private let updatePublisher = PublishSubject() 115 | private let builder: (_ value: Value) -> View 116 | 117 | public init(_ value: Value, builder: @escaping (_ value: Value) -> View) { 118 | self.value = value 119 | self.builder = builder 120 | } 121 | 122 | public func view(at index: Int) -> View? { 123 | guard index == 0 else { return nil } 124 | return builder(value) 125 | } 126 | 127 | public func asViews() -> [View] { 128 | return [builder(value)] 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+ForEach.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+ForEach.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 11/7/21. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ForEach: ViewConvertable { 11 | 12 | private var views: [View] = [] 13 | 14 | public init(_ count: Int, _ builder: (_ index: Int) -> View) { 15 | for index in 0..(_ array: [Element], _ builder: (_ element: Element) -> View) { 21 | views = array.map { builder($0) } 22 | } 23 | 24 | public func asViews() -> [View] { 25 | views 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Group.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+Group.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 7/7/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Group: ViewConvertable { 11 | 12 | private var views: [View] 13 | 14 | public init(@ViewResultBuilder _ views: () -> ViewConvertable) { 15 | self.views = views().asViews() 16 | } 17 | 18 | func asViews() -> [View] { 19 | views 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Image.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+Image.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/29/19. 6 | // Copyright © 2019 Michael Long. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | 12 | public struct ImageView: ModifiableView { 13 | 14 | public let modifiableView = Modified(UIImageView()) 15 | 16 | // lifecycle 17 | public init(_ image: UIImage?) { 18 | modifiableView.image = image 19 | } 20 | 21 | public init(named name: String) { 22 | modifiableView.image = UIImage(named: name) 23 | } 24 | 25 | @available(iOS 13, *) 26 | public init(systemName name: String) { 27 | modifiableView.image = UIImage(systemName: name) 28 | } 29 | 30 | public init(_ image: Binding) where Binding.T == UIImage { 31 | self.image(bind: image) 32 | } 33 | 34 | public init(_ image: Binding) where Binding.T == UIImage? { 35 | self.image(bind: image) 36 | } 37 | 38 | // deprecated 39 | public init(configuration: (_ view: UIImageView) -> Void) { 40 | configuration(modifiableView) 41 | } 42 | 43 | } 44 | 45 | 46 | // Custom UIImageView modifiers 47 | 48 | extension ModifiableView where Base: UIImageView { 49 | 50 | @discardableResult 51 | public func tintColor(_ color: UIColor?) -> ViewModifier { 52 | ViewModifier(modifiableView, keyPath: \.tintColor, value: color) 53 | } 54 | 55 | } 56 | 57 | extension ModifiableView where Base: UIImageView { 58 | 59 | @discardableResult 60 | public func image(bind binding: Binding) -> ViewModifier where Binding.T == UIImage { 61 | ViewModifier(modifiableView, binding: binding) { $0.image = $1 } 62 | } 63 | 64 | @discardableResult 65 | public func image(bind binding: Binding) -> ViewModifier where Binding.T == UIImage? { 66 | ViewModifier(modifiableView, binding: binding) { $0.image = $1 } 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Navigation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+Navigation.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/4/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | 12 | extension UINavigationController { 13 | 14 | @discardableResult 15 | public func push(view: View, animated: Bool) -> Self { 16 | pushViewController(UIViewController(view), animated: animated) 17 | return self 18 | } 19 | 20 | } 21 | 22 | extension ModifiableView { 23 | 24 | @discardableResult 25 | public func navigation(title: String?) -> ViewModifier { 26 | ViewModifier(modifiableView) { 27 | $0.builderAttributes()?.onAppearOnceHandlers.append({ context in 28 | context.navigationItem?.title = title 29 | }) 30 | } 31 | } 32 | 33 | } 34 | 35 | extension UIBarButtonItem { 36 | 37 | convenience public init(barButtonSystemItem systemItem: UIBarButtonItem.SystemItem) { 38 | self.init(barButtonSystemItem: systemItem, target: nil, action: nil) 39 | } 40 | 41 | convenience public init(image: UIImage?, style: UIBarButtonItem.Style) { 42 | self.init(image: image, style: style, target: nil, action: nil) 43 | } 44 | 45 | convenience public init(title: String?, style: UIBarButtonItem.Style) { 46 | self.init(title: title, style: style, target: nil, action: nil) 47 | } 48 | 49 | @discardableResult 50 | public func onTap(_ handler: @escaping (_ item: UIBarButtonItem) -> Void) -> Self { 51 | self.rx.tap 52 | .throttle(.milliseconds(300), latest: false, scheduler: MainScheduler.instance) 53 | .subscribe(onNext: { [unowned self] () in handler(self) }) 54 | .disposed(by: rxDisposeBag) 55 | return self 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Padding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+Padding.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 11/9/21. 6 | // 7 | 8 | import UIKit 9 | 10 | 11 | public protocol ViewBuilderPaddable { 12 | func setPadding(_ padding: UIEdgeInsets) 13 | } 14 | 15 | 16 | extension ModifiableView where Base: ViewBuilderPaddable { 17 | 18 | @discardableResult 19 | public func padding(_ value: CGFloat) -> ViewModifier { 20 | padding(insets: UIEdgeInsets(top: value, left: value, bottom: value, right: value)) 21 | } 22 | 23 | @discardableResult 24 | public func padding(h: CGFloat, v: CGFloat) -> ViewModifier { 25 | padding(insets: UIEdgeInsets(top: v, left: h, bottom: v, right: h)) 26 | } 27 | 28 | @discardableResult 29 | public func padding(top: CGFloat, left: CGFloat, bottom: CGFloat, right: CGFloat) -> ViewModifier { 30 | padding(insets: UIEdgeInsets(top: top, left: left, bottom: bottom, right: right)) 31 | } 32 | 33 | @discardableResult 34 | public func padding(insets: UIEdgeInsets) -> ViewModifier { 35 | ViewModifier(modifiableView) { $0.setPadding(insets) } 36 | } 37 | 38 | } 39 | 40 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+RxSwift.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+RxSwift.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 11/9/21. 6 | // 7 | 8 | import UIKit 9 | import RxSwift 10 | import RxCocoa 11 | 12 | 13 | public protocol RxBinding { 14 | associatedtype T 15 | func asObservable() -> Observable 16 | } 17 | 18 | extension Observable: RxBinding { 19 | // previously defined 20 | } 21 | 22 | 23 | 24 | 25 | public protocol RxBidirectionalBinding: RxBinding { 26 | associatedtype T 27 | func asRelay() -> BehaviorRelay 28 | } 29 | 30 | extension BehaviorRelay: RxBidirectionalBinding { 31 | public func asRelay() -> BehaviorRelay { self } 32 | } 33 | 34 | 35 | 36 | extension ViewModifier { 37 | 38 | public init(_ view: Base, binding: B, handler: @escaping (_ view: Base, _ value: T) -> Void) where B.T == T { 39 | self.modifiableView = view 40 | binding.asObservable() 41 | .observe(on: ConcurrentMainScheduler.instance) 42 | .subscribe(onNext: { [weak view] value in 43 | if let view = view { 44 | handler(view, value) 45 | } 46 | }) 47 | .disposed(by: view.rxDisposeBag) 48 | } 49 | 50 | public init(_ view: Base, binding: B, keyPath: ReferenceWritableKeyPath) where B.T == T { 51 | self.modifiableView = view 52 | binding.asObservable() 53 | .observe(on: ConcurrentMainScheduler.instance) 54 | .subscribe(onNext: { [weak view] value in 55 | if let view = view, view[keyPath: keyPath] != value { 56 | view[keyPath: keyPath] = value 57 | } 58 | }) 59 | .disposed(by: view.rxDisposeBag) 60 | } 61 | 62 | } 63 | 64 | extension ModifiableView { 65 | 66 | @discardableResult 67 | public func bind(keyPath: ReferenceWritableKeyPath, binding: B) -> ViewModifier where B.T == T { 68 | ViewModifier(modifiableView) { 69 | binding.asObservable() 70 | .observe(on: ConcurrentMainScheduler.instance) 71 | .subscribe(onNext: { [weak modifiableView] value in 72 | modifiableView?[keyPath: keyPath] = value 73 | }) 74 | .disposed(by: $0.rxDisposeBag) 75 | } 76 | } 77 | 78 | @discardableResult 79 | public func onReceive(_ binding: B, handler: @escaping (_ context: ViewBuilderValueContext) -> Void) 80 | -> ViewModifier where B.T == T { 81 | ViewModifier(modifiableView) { 82 | binding.asObservable() 83 | .observe(on: ConcurrentMainScheduler.instance) 84 | .subscribe(onNext: { [weak modifiableView] value in 85 | if let view = modifiableView { 86 | handler(ViewBuilderValueContext(view: view, value: value)) 87 | } 88 | }) 89 | .disposed(by: $0.rxDisposeBag) 90 | } 91 | } 92 | 93 | } 94 | 95 | extension NSObject { 96 | 97 | private static var RxDisposeBagAttributesKey: UInt8 = 0 98 | 99 | public var rxDisposeBag: DisposeBag { 100 | if let disposeBag = objc_getAssociatedObject( self, &UIView.RxDisposeBagAttributesKey ) as? DisposeBag { 101 | return disposeBag 102 | } 103 | let disposeBag = DisposeBag() 104 | objc_setAssociatedObject(self, &UIView.RxDisposeBagAttributesKey, disposeBag, .OBJC_ASSOCIATION_RETAIN) 105 | return disposeBag 106 | } 107 | 108 | } 109 | 110 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Spacer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+View.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/29/19. 6 | // Copyright © 2019 Michael Long. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public struct SpacerView: ModifiableView { 12 | 13 | public var modifiableView = Modified(UIView()) 14 | 15 | public init() { 16 | modifiableView.setContentHuggingPriority(.defaultLow, for: .horizontal) 17 | modifiableView.setContentHuggingPriority(.defaultLow, for: .vertical) 18 | } 19 | 20 | public init(_ height: CGFloat = 16) { 21 | modifiableView.heightAnchor.constraint(greaterThanOrEqualToConstant: height).isActive = true 22 | modifiableView.setContentCompressionResistancePriority(.required, for: .vertical) 23 | } 24 | 25 | public init(width: CGFloat = 8) { 26 | modifiableView.widthAnchor.constraint(greaterThanOrEqualToConstant: width).isActive = true 27 | modifiableView.setContentCompressionResistancePriority(.required, for: .horizontal) 28 | } 29 | 30 | } 31 | 32 | public struct FixedSpacerView: ModifiableView { 33 | 34 | public var modifiableView = Modified(UIView()) 35 | 36 | public init() { 37 | modifiableView.setContentHuggingPriority(.required, for: .horizontal) 38 | modifiableView.setContentHuggingPriority(.required, for: .vertical) 39 | } 40 | 41 | public init(_ height: CGFloat = 16) { 42 | modifiableView.heightAnchor.constraint(equalToConstant: height).isActive = true 43 | modifiableView.setContentCompressionResistancePriority(.required, for: .vertical) 44 | } 45 | 46 | public init(width: CGFloat = 8) { 47 | modifiableView.widthAnchor.constraint(equalToConstant: width).isActive = true 48 | modifiableView.setContentCompressionResistancePriority(.required, for: .horizontal) 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Styles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+Styles.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/29/21. 6 | // 7 | 8 | import UIKit 9 | 10 | 11 | public protocol BuilderStyle { 12 | associatedtype Base: UIView 13 | func apply(to view: Base) 14 | } 15 | 16 | extension ModifiableView { 17 | 18 | @discardableResult 19 | public func style(_ style: Style) -> ViewModifier where Style.Base == Base { 20 | ViewModifier(modifiableView) { style.apply(to: $0) } 21 | } 22 | 23 | @discardableResult 24 | public func style(_ style: Style) -> ViewModifier where Style.Base == UIView { 25 | ViewModifier(modifiableView) { style.apply(to: $0) } 26 | } 27 | 28 | } 29 | 30 | //func test() { 31 | // LabelView("Some text") 32 | // .style(StyleLabelAccentTitle()) 33 | // ButtonView("Some text") 34 | // .style(StyleButtonFilled()) 35 | //} 36 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Switch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+Switch.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 11/9/21. 6 | // 7 | 8 | import UIKit 9 | import RxSwift 10 | 11 | // Custom builder fot UILabel 12 | public struct SwitchView: ModifiableView { 13 | 14 | public let modifiableView: UISwitch = Modified(UISwitch()) { 15 | $0.onTintColor = ViewBuilderEnvironment.defaultButtonColor 16 | $0.contentHuggingPriority(.required, for: .horizontal) 17 | } 18 | 19 | // lifecycle 20 | public init(_ isOn: Bool = true) { 21 | modifiableView.isOn = isOn 22 | } 23 | 24 | public init(_ binding: Binding) where Binding.T == Bool { 25 | isOn(bind: binding) 26 | } 27 | 28 | public init(_ binding: Binding) where Binding.T == Bool { 29 | isOn(bidirectionalBind: binding) 30 | } 31 | 32 | } 33 | 34 | 35 | // Custom UILabel modifiers 36 | extension ModifiableView where Base: UISwitch { 37 | 38 | @discardableResult 39 | public func isOn(bind binding: Binding) -> ViewModifier where Binding.T == Bool { 40 | ViewModifier(modifiableView, binding: binding, keyPath: \.isOn) 41 | } 42 | 43 | @discardableResult 44 | public func isOn(bidirectionalBind binding: Binding) -> ViewModifier where Binding.T == Bool { 45 | ViewModifier(modifiableView) { switchView in 46 | let relay = binding.asRelay() 47 | switchView.rxDisposeBag.insert( 48 | relay 49 | .observe(on: ConcurrentMainScheduler.instance) 50 | .subscribe(onNext: { [weak switchView] value in 51 | if let view = switchView, view.isOn != value { 52 | view.isOn = value 53 | } 54 | }), 55 | switchView.rx.isOn 56 | .subscribe(onNext: { [weak relay] value in 57 | if let relay = relay, relay.value != value { 58 | relay.accept(value) 59 | } 60 | }) 61 | ) 62 | } 63 | } 64 | 65 | @discardableResult 66 | public func onTintColor(_ color: UIColor?) -> ViewModifier { 67 | ViewModifier(modifiableView, keyPath: \.onTintColor, value: color) 68 | } 69 | 70 | @discardableResult 71 | public func onChange(_ handler: @escaping (_ context: ViewBuilderValueContext) -> Void) -> ViewModifier { 72 | ViewModifier(modifiableView) { 73 | $0.rx.isOn 74 | .changed 75 | .subscribe(onNext: { [unowned modifiableView] value in 76 | handler(ViewBuilderValueContext(view: modifiableView, value: modifiableView.isOn)) 77 | }) 78 | .disposed(by: $0.rxDisposeBag) 79 | } 80 | } 81 | 82 | 83 | } 84 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+Variable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+Variable.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/23/21. 6 | // 7 | 8 | import Foundation 9 | import RxSwift 10 | import RxCocoa 11 | 12 | @propertyWrapper public struct Variable { 13 | 14 | private var relay: BehaviorRelay 15 | 16 | public init(_ relay: BehaviorRelay) { 17 | self.relay = relay 18 | } 19 | 20 | public var wrappedValue: T { 21 | get { return relay.value } 22 | nonmutating set { relay.accept(newValue) } 23 | } 24 | 25 | public var projectedValue: Variable { 26 | get { return self } 27 | } 28 | 29 | } 30 | 31 | extension Variable { 32 | 33 | public init(wrappedValue: T) { 34 | self.relay = BehaviorRelay(value: wrappedValue) 35 | } 36 | 37 | } 38 | 39 | extension Variable where T:Equatable { 40 | 41 | public func onChange(_ observer: @escaping (_ value: T) -> ()) -> Disposable { 42 | relay 43 | .skip(1) 44 | .distinctUntilChanged() 45 | .subscribe { observer($0) } 46 | } 47 | 48 | } 49 | 50 | extension Variable: RxBinding { 51 | 52 | public func asObservable() -> Observable { 53 | return relay.asObservable() 54 | } 55 | 56 | public func observe(on scheduler: ImmediateSchedulerType) -> Observable { 57 | return relay.observe(on: scheduler) 58 | } 59 | 60 | public func bind(_ observable: Observable) -> Disposable { 61 | return observable.bind(to: relay) 62 | } 63 | 64 | } 65 | 66 | extension Variable: RxBidirectionalBinding { 67 | public func asRelay() -> BehaviorRelay { 68 | return relay 69 | } 70 | 71 | } 72 | 73 | //struct A: ViewBuilder { 74 | // @Variable var name = "Michael" 75 | // var body: View { 76 | // B(name: $name) 77 | // } 78 | //} 79 | // 80 | //struct B: ViewBuilder { 81 | // @Variable var name: String 82 | // var body: View { 83 | // LabelView(name) 84 | // } 85 | //} 86 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+ViewController.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 10/4/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIViewController { 12 | 13 | convenience public init(_ view: View, padding: UIEdgeInsets? = nil, safeArea: Bool = false) { 14 | self.init() 15 | if #available(iOS 13, *) { 16 | self.view.backgroundColor = .systemBackground 17 | } else { 18 | self.view.backgroundColor = .white 19 | } 20 | self.view.embed(view(), padding: padding, safeArea: safeArea) 21 | } 22 | 23 | public func transition(to view: View, padding: UIEdgeInsets? = nil, safeArea: Bool = false, delay: Double = 0.2) { 24 | self.view.transition(to: view, padding: padding, safeArea: safeArea, delay: delay) 25 | } 26 | 27 | public func transition(to viewController: UIViewController, delay: Double) { 28 | self.view.transition(to: viewController, delay: delay) 29 | } 30 | 31 | } 32 | 33 | public struct ViewControllerHostView: ModifiableView { 34 | 35 | public var modifiableView = Modified(BuilderInternalViewControllerHostView()) { 36 | $0.backgroundColor = .clear 37 | } 38 | 39 | public init(_ viewController: UIViewController) { 40 | modifiableView.viewController = viewController 41 | } 42 | 43 | } 44 | 45 | public class BuilderInternalViewControllerHostView: UIView { 46 | 47 | var viewController: UIViewController! 48 | 49 | public init() { 50 | super.init(frame: .zero) 51 | } 52 | 53 | required init?(coder: NSCoder) { 54 | fatalError("init(coder:) has not been implemented") 55 | } 56 | 57 | override public func didMoveToWindow() { 58 | if subviews.isEmpty, let parentViewController = parentViewController { 59 | parentViewController.addChild(viewController) 60 | embed(viewController.view) 61 | viewController.didMove(toParent: parentViewController) 62 | } 63 | } 64 | 65 | override public func didMoveToSuperview() { 66 | if superview == nil { 67 | viewController?.willMove(toParent: nil) 68 | viewController?.view.removeFromSuperview() 69 | viewController?.removeFromParent() 70 | } 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+With.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+With.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 7/2/21. 6 | // 7 | 8 | import UIKit 9 | 10 | public typealias With = ViewModifier 11 | -------------------------------------------------------------------------------- /Sources/Builder/Builder/Builder+ZStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Builder+ZStack.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 1/30/21. 6 | // 7 | 8 | import UIKit 9 | 10 | public typealias ZStackView = ContainerView 11 | -------------------------------------------------------------------------------- /Sources/Builder/Extensions/UIColor+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor+Extensions.swift 3 | // Builder 4 | // 5 | // Created by Michael Long on 11/26/21. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIColor { 11 | 12 | func lighter(by percentage: CGFloat = 30.0) -> UIColor { 13 | return self.adjust(by: abs(percentage)) 14 | } 15 | 16 | func darker(by percentage: CGFloat = 30.0) -> UIColor { 17 | return self.adjust(by: -1 * abs(percentage)) 18 | } 19 | 20 | private func adjust(by percentage: CGFloat = 30.0) -> UIColor { 21 | var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 22 | if self.getRed(&red, green: &green, blue: &blue, alpha: &alpha) { 23 | return UIColor(red: min(red + percentage/100, 1.0), 24 | green: min(green + percentage/100, 1.0), 25 | blue: min(blue + percentage/100, 1.0), 26 | alpha: alpha) 27 | } 28 | return self 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Builder/Extensions/UIImage+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+Extensions.swift 3 | // ViewBuilder 4 | // 5 | // Created by Michael Long on 9/26/20. 6 | // Copyright © 2020 Michael Long. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIImage { 12 | 13 | /** 14 | Initializes and returns the image object of the specified color and size. 15 | */ 16 | public convenience init?(color: UIColor, size: CGSize = CGSize(width: 1, height: 1)) { 17 | let rect = CGRect(origin: .zero, size: size) 18 | UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0) 19 | color.setFill() 20 | UIRectFill(rect) 21 | let image = UIGraphicsGetImageFromCurrentImageContext() 22 | UIGraphicsEndImageContext() 23 | 24 | guard let cgImage = image?.cgImage else { 25 | return nil 26 | } 27 | self.init(cgImage: cgImage) 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /Tests/BuilderTests/BuilderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Builder 3 | 4 | final class BuilderTests: XCTestCase { 5 | func testExample() throws { 6 | } 7 | } 8 | --------------------------------------------------------------------------------