├── .gitignore ├── Fields.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── xcuserdata │ └── aleck.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── Fields ├── CellModels │ ├── DatePickerModel.swift │ ├── FieldModel.swift │ ├── FieldSection.swift │ ├── FormButtonModel.swift │ ├── FormTextModel.swift │ ├── PickerModel.swift │ ├── SingleValueModel.swift │ ├── TextFieldModel.swift │ ├── TextViewModel.swift │ └── ToggleModel.swift ├── Cells │ ├── DatePickerCell.swift │ ├── DatePickerCell.xib │ ├── DatePickerCellAccessoryView.swift │ ├── DatePickerCellAccessoryView.xib │ ├── FormButtonCell.swift │ ├── FormButtonCell.xib │ ├── FormFieldCell.swift │ ├── FormSupplementaryView.swift │ ├── FormTextCell.swift │ ├── FormTextCell.xib │ ├── PickerCell.swift │ ├── PickerCell.xib │ ├── PickerStackCell.swift │ ├── PickerStackCell.xib │ ├── PickerStackItem.swift │ ├── PickerStackItem.xib │ ├── SegmentsCell.swift │ ├── SegmentsCell.xib │ ├── SelfSizingHeightCell.swift │ ├── SeparatorLineView.swift │ ├── TextFieldCell.swift │ ├── TextFieldCell.xib │ ├── TextViewCell.swift │ ├── TextViewCell.xib │ ├── ToggleCell.swift │ └── ToggleCell.xib ├── Controllers │ ├── PickerOptionTextCell.swift │ ├── PickerOptionTextCell.xib │ ├── PickerOptionsListController.swift │ └── PickerOptionsProvider.swift ├── FieldsCollectionController.swift ├── FieldsController.swift ├── FieldsDataSource.swift └── UIResponder-FieldsDelegate.swift ├── LICENSE ├── README.md ├── Vendor ├── Controllers │ ├── Embeddable.swift │ └── StoryboardLoadable.swift ├── Notifications │ ├── Notifying.swift │ ├── UIContentSizeCategory-Notifications.swift │ └── UIKeyboard-Notifications.swift ├── UIKit │ ├── ActionClosurable.swift │ ├── UIButton-Extensions.swift │ ├── UIColor-Extensions.swift │ ├── UIEdgeInsets-Extensions.swift │ └── UIStackView-Extensions.swift └── Views │ ├── DequeableView.swift │ ├── NibLoadable.swift │ └── ReusableView.swift └── demo-app ├── AppDelegate.swift ├── Assets.xcassets ├── AppIcon.appiconset │ └── Contents.json ├── Contents.json ├── customer_service.imageset │ ├── Contents.json │ └── customer_service@2x.png ├── icon-dress.imageset │ ├── Contents.json │ └── Fill 267.pdf ├── icon-handbag.imageset │ ├── Contents.json │ └── Fill 274.pdf ├── icon-makeup.imageset │ ├── Contents.json │ └── Fill 266.pdf ├── icon-tshirt.imageset │ ├── Contents.json │ └── Fill 264.pdf ├── icon-underwear.imageset │ ├── Contents.json │ └── Fill 265.pdf └── icon-watch.imageset │ ├── Contents.json │ └── Fill 263.pdf ├── Base.lproj └── LaunchScreen.storyboard ├── CustomCells ├── ForgotPassCell.swift ├── ForgotPassCell.xib ├── InventoryCategoryCell.swift ├── InventoryCategoryCell.xib ├── OptionCell.swift ├── OptionCell.xib ├── SectionFooterView.swift ├── SectionFooterView.xib ├── SectionHeaderView.swift └── SectionHeaderView.xib ├── DataModel ├── Address.swift ├── InventoryCategory.swift └── User.swift ├── ForgotPasswordController.storyboard ├── ForgotPasswordController.swift ├── ForgotPasswordDataSource.swift ├── Info.plist ├── LoginController.swift ├── LoginDataSource.swift ├── RegisterController.swift ├── RegisterDataSource.swift └── ViewModels └── InventoryCategory-Extensions.swift /.gitignore: -------------------------------------------------------------------------------- 1 | stuff/ 2 | 3 | # Xcode 4 | # 5 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 6 | 7 | ## Build generated 8 | 9 | build/ 10 | DerivedData/ 11 | 12 | ## Various settings 13 | 14 | *.pbxuser 15 | !default.pbxuser 16 | *.mode1v3 17 | !default.mode1v3 18 | *.mode2v3 19 | !default.mode2v3 20 | *.perspectivev3 21 | !default.perspectivev3 22 | xcuserdata/ 23 | 24 | ## Other 25 | 26 | *.moved-aside 27 | *.xccheckout 28 | *.xcscmblueprint 29 | 30 | ## Obj-C/Swift specific 31 | 32 | *.hmap 33 | *.ipa 34 | *.dSYM.zip 35 | *.dSYM 36 | 37 | ## Playgrounds 38 | 39 | timeline.xctimeline 40 | playground.xcworkspace 41 | 42 | # Swift Package Manager 43 | # 44 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 45 | # Packages/ 46 | # Package.pins 47 | # Package.resolved 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | 57 | Pods/ 58 | 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build 68 | 69 | # fastlane 70 | # 71 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 72 | # screenshots whenever they are needed. 73 | # For more information about the recommended setup visit: 74 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 75 | 76 | fastlane/report.xml 77 | fastlane/Preview.html 78 | fastlane/screenshots/**/*.png 79 | fastlane/test_output 80 | 81 | # Code Injection 82 | # 83 | # After new code Injection tools there's a generated folder /iOSInjectionProject 84 | # https://github.com/johnno1962/injectionforxcode 85 | 86 | iOSInjectionProject/ 87 | -------------------------------------------------------------------------------- /Fields.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Fields.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Fields.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Fields.xcodeproj/xcuserdata/aleck.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Fields-demo.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | Fields.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 0 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Fields/CellModels/DatePickerModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatePickerModel.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | /// Model that corresponds to DatePickerCell instance. 12 | class DatePickerModel: FieldModel, @unchecked Sendable { 13 | /// String to display in the title label 14 | var title: String? 15 | 16 | /// Chosen date 17 | var value: Date? 18 | 19 | /// Timestamp to show if `value` is not set 20 | var placeholder: Date 21 | 22 | /// Instance of DateFormatter to use and build String representation 23 | var formatter: DateFormatter 24 | 25 | /// Custom configuration for the date picker. 26 | /// 27 | /// Default implementation does nothing. 28 | var customSetup: (UIDatePicker, FormFieldCell) -> Void = {_, _ in} 29 | 30 | /// Method called every time value of the picker changes. 31 | /// 32 | /// Default implementation does nothing. 33 | var valueChanged: (Date?, FormFieldCell) -> Void = {_, _ in} 34 | 35 | init(id: String, 36 | title: String? = nil, 37 | value: Date? = nil, 38 | placeholder: Date = Date(), 39 | formatter: DateFormatter, 40 | customSetup: @escaping (UIDatePicker, FormFieldCell) -> Void = {_, _ in}, 41 | valueChanged: @escaping (Date?, FormFieldCell) -> Void = {_, _ in} 42 | ){ 43 | self.title = title 44 | self.value = value 45 | self.placeholder = placeholder 46 | self.formatter = formatter 47 | super.init(id: id) 48 | 49 | self.customSetup = customSetup 50 | self.valueChanged = valueChanged 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /Fields/CellModels/FieldModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FieldModel.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import Foundation 10 | 11 | /// Base Model used for totally custom cells, which are hard to fit into any of the others. So they only have `id` 12 | /// 13 | /// They don't have `value`, as it's expected that cells using them will provide case-by-case value and formatting. 14 | class FieldModel: Hashable, Identifiable, @unchecked Sendable { 15 | /// unique identifier (across the containing form) for this field 16 | let id: String 17 | 18 | var isRequired = false 19 | var shouldRenderRequired = true 20 | 21 | var isUserInteractive = true 22 | var isEnabled = true 23 | 24 | init(id: String) { 25 | self.id = id 26 | } 27 | 28 | static func == (lhs: FieldModel, rhs: FieldModel) -> Bool { 29 | lhs.id == rhs.id && 30 | lhs.isRequired == rhs.isRequired && 31 | lhs.shouldRenderRequired == rhs.shouldRenderRequired && 32 | lhs.isUserInteractive == rhs.isUserInteractive && 33 | lhs.isEnabled == rhs.isEnabled 34 | } 35 | 36 | func hash(into hasher: inout Hasher) { 37 | hasher.combine(id) 38 | hasher.combine(isRequired) 39 | hasher.combine(shouldRenderRequired) 40 | hasher.combine(isUserInteractive) 41 | hasher.combine(isEnabled) 42 | } 43 | 44 | var hasError: Bool { 45 | return false 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Fields/CellModels/FieldSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FieldSection.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import Foundation 10 | 11 | struct FieldSection: Hashable, Identifiable { 12 | let id: String 13 | 14 | var header: String? = nil 15 | var fieldIds: [FieldModel.ID] = [] 16 | var footer: String? = nil 17 | 18 | init(id: String, header: String? = nil, footer: String? = nil, fieldIds: [FieldModel.ID] = []) { 19 | self.id = id 20 | self.header = header 21 | self.footer = footer 22 | self.fieldIds = fieldIds 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Fields/CellModels/FormButtonModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormButtonModel.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | /// Model that corresponds to SubmitCell instance. 12 | class FormButtonModel: FieldModel, @unchecked Sendable { 13 | /// Button caption 14 | var title: String 15 | 16 | /// Custom configuration for the `UIButton` 17 | /// 18 | /// Default implementation does nothing. 19 | var customSetup: (UIButton) -> Void = {_ in} 20 | 21 | /// Action to perform when button is tapped, with completion closure that must be called at the end of your `action` implementation. 22 | /// 23 | /// The common UI flow here is that activity-indicator will appear and start animating when you tap; then `completed()` closure would stop indicator animation and hide it. 24 | var action: () -> Void = {} 25 | 26 | init(id: String, 27 | title: String, 28 | customSetup: @escaping (UIButton) -> Void = {_ in}, 29 | action: @escaping () -> Void = {} 30 | ){ 31 | self.title = title 32 | super.init(id: id) 33 | 34 | self.customSetup = customSetup 35 | self.action = action 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /Fields/CellModels/FormTextModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormTextModel.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | /// Model that corresponds to TextCell instance. 12 | class FormTextModel: FieldModel, @unchecked Sendable { 13 | /// Title text, explaining what the `value` is 14 | var title: String 15 | 16 | /// Text that represent the shown value 17 | var value: String 18 | 19 | /// Custom configuration for the `UILabel` showing the `value` string. 20 | /// 21 | /// Default implementation does nothing. 22 | var customSetup: (UILabel) -> Void = {_ in} 23 | 24 | init(id: String, 25 | title: String, 26 | value: String, 27 | customSetup: @escaping (UILabel) -> Void = {_ in} 28 | ){ 29 | self.title = title 30 | self.value = value 31 | super.init(id: id) 32 | 33 | self.customSetup = customSetup 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /Fields/CellModels/PickerModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PickerModel.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | /// Model that corresponds to PickerCell instance. 12 | /// 13 | /// 14 | class PickerModel: FieldModel, @unchecked Sendable { 15 | /// String to display in the title label 16 | var title: String? 17 | 18 | /// String to display when there is no value 19 | var placeholder: String? 20 | 21 | /// Pre-selected value of type `T` 22 | var value: T? 23 | 24 | /// List of allowed values of type `T`, to choose from. 25 | var values: [T] = [] 26 | 27 | /// Transforms value of `T` into String, so it can be shown inside the field and also in expanded options list cells. 28 | var valueFormatter: (T?) -> String? 29 | 30 | var isPickerShown = false 31 | 32 | /// Executted when PickerCell is tapped. It should display the `values` list. 33 | var displayPicker: (FormFieldCell) -> Void = {_ in} 34 | 35 | /// Method called every time a value is picked. 36 | /// 37 | /// Last parameter should be `true` when using the picker in pushed VC, otherwise `false` to not do `popViewController` on selection. 38 | /// 39 | /// Default implementation does nothing. 40 | var selectedValueAtIndex: (Int?, FormFieldCell, Bool) -> Void = {_, _, _ in} 41 | 42 | init(id: String, 43 | title: String? = nil, 44 | placeholder: String? = nil, 45 | value: T? = nil, 46 | values: [T] = [], 47 | valueFormatter: @escaping (T?) -> String?, 48 | displayPicker: @escaping (FormFieldCell) -> Void = {_ in}, 49 | selectedValueAtIndex: @escaping (Int?, FormFieldCell, Bool) -> Void = {_, _, _ in} 50 | ){ 51 | self.title = title 52 | self.placeholder = placeholder 53 | self.value = value 54 | self.values = values 55 | self.valueFormatter = valueFormatter 56 | super.init(id: id) 57 | 58 | self.displayPicker = displayPicker 59 | self.selectedValueAtIndex = selectedValueAtIndex 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /Fields/CellModels/SingleValueModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SingleValueModel.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | /// Model where you pick a particular `value` (out of set of possible values) by selecting the corresponding cell. 12 | /// 13 | /// Each model/cell is mutually exclusive, thus this is most likely to be part of one section. 14 | class SingleValueModel: FieldModel, @unchecked Sendable { 15 | /// String to display in the title label 16 | var title: String? 17 | 18 | /// Assigned value of type `T` 19 | var value: T 20 | 21 | var isChosen = false 22 | 23 | /// Method that should be called when `FieldCell` using this model's instance is selected. 24 | /// 25 | /// Default implementation does nothing. 26 | var valueSelected: (T, FormFieldCell) -> Void = {_, _ in} 27 | 28 | init(id: String, 29 | title: String? = nil, 30 | value: T, 31 | isChosen: Bool = false, 32 | valueSelected: @escaping (T, FormFieldCell) -> Void = {_, _ in} 33 | ){ 34 | self.isChosen = isChosen 35 | self.title = title 36 | self.value = value 37 | super.init(id: id) 38 | 39 | self.valueSelected = valueSelected 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /Fields/CellModels/TextFieldModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextFieldModel.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | /// Model that corresponds to TextFieldCell instance. 12 | class TextFieldModel: FieldModel, @unchecked Sendable { 13 | /// String to display in the title label 14 | var title: String? 15 | 16 | /// Value to show inside the textField 17 | var value: String? 18 | 19 | /// Placeholder value to show inside the textField 20 | var placeholder: String? 21 | 22 | /// Custom configuration for the textField. 23 | /// 24 | /// Default implementation does nothing. 25 | var customSetup: (UITextField) -> Void = {_ in} 26 | 27 | /// Method called every time value inside the field changes. 28 | /// 29 | /// Default implementation does nothing. 30 | var valueChanged: (String?, FormFieldCell) -> Void = {_, _ in} 31 | 32 | init(id: String, 33 | title: String? = nil, 34 | value: String? = nil, 35 | placeholder: String? = nil, 36 | customSetup: @escaping (UITextField) -> Void = {_ in}, 37 | valueChanged: @escaping (String?, FormFieldCell) -> Void = {_, _ in} 38 | ){ 39 | self.title = title 40 | self.value = value 41 | self.placeholder = placeholder 42 | super.init(id: id) 43 | 44 | self.customSetup = customSetup 45 | self.valueChanged = valueChanged 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /Fields/CellModels/TextViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextViewModel.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | /// Model that corresponds to TextViewCell instance. 12 | class TextViewModel: FieldModel, @unchecked Sendable { 13 | /// Minimal size of the text-view 14 | var minimalHeight: CGFloat 15 | 16 | /// String to display in the title label 17 | var title: String? 18 | 19 | /// Value to show inside the textField 20 | var value: String? 21 | 22 | /// Custom configuration for the textView. 23 | /// 24 | /// Default implementation does nothing. 25 | var customSetup: (UITextView) -> Void = {_ in} 26 | 27 | /// Method called every time value inside the field changes. 28 | /// 29 | /// Default implementation does nothing. 30 | var valueChanged: (String?, FormFieldCell) -> Void = {_, _ in} 31 | 32 | init(id: String, 33 | minimalHeight: CGFloat = 60, 34 | title: String? = nil, 35 | value: String? = nil, 36 | customSetup: @escaping (UITextView) -> Void = {_ in}, 37 | valueChanged: @escaping (String?, FormFieldCell) -> Void = {_, _ in} 38 | ){ 39 | self.title = title 40 | self.value = value 41 | self.minimalHeight = minimalHeight 42 | super.init(id: id) 43 | 44 | self.customSetup = customSetup 45 | self.valueChanged = valueChanged 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /Fields/CellModels/ToggleModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToggleModel.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | /// Model that corresponds to ToggleCell instance. 12 | class ToggleModel: FieldModel, @unchecked Sendable { 13 | /// String to next to the `UISwitch` 14 | var title: String 15 | 16 | /// Value for the `UISwitch` 17 | var value: Bool 18 | 19 | /// Custom configuration for the `UISwitch`. 20 | /// 21 | /// Default implementation does nothing. 22 | var customSetup: (UISwitch) -> Void = {_ in} 23 | 24 | /// Method called every time UISwitch is toggled. 25 | /// 26 | /// Default implementation does nothing. 27 | var valueChanged: (Bool, FormFieldCell) -> Void = {_, _ in} 28 | 29 | init(id: String, 30 | title: String, 31 | value: Bool, 32 | customSetup: @escaping (UISwitch) -> Void = {_ in}, 33 | valueChanged: @escaping (Bool, FormFieldCell) -> Void = {_, _ in} 34 | ){ 35 | self.title = title 36 | self.value = value 37 | super.init(id: id) 38 | 39 | self.customSetup = customSetup 40 | self.valueChanged = valueChanged 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /Fields/Cells/DatePickerCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatePickerCell.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | final class DatePickerCell: FormFieldCell, NibReusableView { 12 | // UI 13 | @IBOutlet private var titleLabel: UILabel! 14 | @IBOutlet private var valueField: UITextField! 15 | 16 | @IBOutlet private var setButton: UIButton! 17 | 18 | private weak var formatter: DateFormatter! 19 | private var originalValue: Date? 20 | private var valueChanged: (Date?, DatePickerCell) -> Void = {_, _ in} 21 | private var customSetup: (UIDatePicker, FormFieldCell) -> Void = {_, _ in} 22 | 23 | private var picker: UIDatePicker? 24 | } 25 | 26 | extension DatePickerCell { 27 | override func postAwakeFromNib() { 28 | super.postAwakeFromNib() 29 | cleanup() 30 | 31 | valueField.tintColor = valueField.superview?.backgroundColor 32 | } 33 | 34 | override func prepareForReuse() { 35 | super.prepareForReuse() 36 | cleanup() 37 | } 38 | 39 | func populate(with model: DatePickerModel) { 40 | originalValue = model.value 41 | formatter = model.formatter 42 | valueChanged = model.valueChanged 43 | customSetup = model.customSetup 44 | render(model) 45 | } 46 | 47 | override func updateConstraints() { 48 | titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width 49 | 50 | super.updateConstraints() 51 | } 52 | } 53 | 54 | private extension DatePickerCell { 55 | func cleanup() { 56 | titleLabel.text = nil 57 | valueField.text = nil 58 | } 59 | 60 | func render(_ model: DatePickerModel) { 61 | titleLabel.text = model.title 62 | if let date = model.value { 63 | valueField.text = formatter.string(from: date) 64 | } 65 | } 66 | 67 | func prepareInputAccessoryView() -> DatePickerCellAccessoryView { 68 | let v = DatePickerCellAccessoryView.nibInstance 69 | v.frame.size.height = 44 70 | 71 | v.saveButton.addTarget(self, action: #selector(save), for: .touchUpInside) 72 | v.cancelButton.addTarget(self, action: #selector(cancel), for: .touchUpInside) 73 | return v 74 | } 75 | 76 | @IBAction func set(_ sender: UIButton) { 77 | let picker = UIDatePicker(frame: .zero) 78 | if #available(iOS 14.0, *) { 79 | picker.preferredDatePickerStyle = .wheels 80 | } 81 | if let value = originalValue { 82 | picker.date = value 83 | } 84 | self.picker = picker 85 | customSetup(picker, self) 86 | 87 | valueField.inputView = picker 88 | valueField.inputAccessoryView = prepareInputAccessoryView() 89 | valueField.becomeFirstResponder() 90 | 91 | setButton.isHidden = true 92 | } 93 | 94 | @objc func save(_ sender: UIButton) { 95 | defer { 96 | valueField.resignFirstResponder() 97 | picker = nil 98 | setButton.isHidden = false 99 | } 100 | 101 | guard let date = picker?.date else { return } 102 | originalValue = date 103 | 104 | valueField.text = formatter.string(from: date) 105 | valueChanged(date, self) 106 | } 107 | 108 | @objc func cancel(_ sender: UIButton) { 109 | defer { 110 | valueField.resignFirstResponder() 111 | picker = nil 112 | setButton.isHidden = false 113 | } 114 | 115 | if let date = originalValue { 116 | valueField.text = formatter.string(from: date) 117 | } else { 118 | valueField.text = nil 119 | } 120 | valueChanged(originalValue, self) 121 | } 122 | } 123 | 124 | -------------------------------------------------------------------------------- /Fields/Cells/DatePickerCellAccessoryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatePickerCellAccessoryView.swift 3 | // Fields-demo 4 | // 5 | // Created by Aleksandar Vacić on 8/16/19. 6 | // Copyright © 2019 Radiant Tap. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class DatePickerCellAccessoryView: UIView, NibLoadableFinalView { 12 | @IBOutlet private(set) var cancelButton: UIButton! 13 | @IBOutlet private(set) var saveButton: UIButton! 14 | } 15 | -------------------------------------------------------------------------------- /Fields/Cells/DatePickerCellAccessoryView.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 28 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /Fields/Cells/FormButtonCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormButtonCell.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | final class FormButtonCell: FormFieldCell, NibLoadableFinalView, NibReusableView { 12 | // UI 13 | @IBOutlet private var button: UIButton! 14 | 15 | private var action: () -> Void = {} 16 | } 17 | 18 | extension FormButtonCell { 19 | override func postAwakeFromNib() { 20 | super.postAwakeFromNib() 21 | cleanup() 22 | } 23 | 24 | override func prepareForReuse() { 25 | super.prepareForReuse() 26 | cleanup() 27 | } 28 | 29 | func populate(with model: FormButtonModel) { 30 | render(model) 31 | } 32 | } 33 | 34 | private extension FormButtonCell { 35 | func cleanup() { 36 | button.setTitle(nil, for: .normal) 37 | 38 | button.removeTarget(self, action: nil, for: .touchUpInside) 39 | } 40 | 41 | func render(_ model: FormButtonModel) { 42 | button.setTitle(model.title, for: .normal) 43 | model.customSetup(button) 44 | 45 | action = model.action 46 | 47 | button.addTarget(self, action: #selector(tapped), for: .touchUpInside) 48 | } 49 | 50 | @objc func tapped(_ sender: UIButton) { 51 | action() 52 | } 53 | } 54 | 55 | -------------------------------------------------------------------------------- /Fields/Cells/FormButtonCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /Fields/Cells/FormFieldCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormFieldCell.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | class FormFieldCell: SelfSizingHeightCell { 12 | override func awakeFromNib() { 13 | super.awakeFromNib() 14 | MainActor.assumeIsolated { 15 | self.postAwakeFromNib() 16 | } 17 | } 18 | 19 | @objc dynamic func postAwakeFromNib() {} 20 | 21 | func commonRender(_ model: FieldModel) { 22 | isUserInteractionEnabled = model.isUserInteractive && model.isEnabled 23 | 24 | contentView.alpha = model.isEnabled ? 1 : 0.6 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Fields/Cells/FormSupplementaryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormSupplementaryView.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | class FormSupplementaryView: UICollectionReusableView { 12 | override func awakeFromNib() { 13 | super.awakeFromNib() 14 | MainActor.assumeIsolated { 15 | self.postAwakeFromNib() 16 | } 17 | } 18 | 19 | @objc dynamic func postAwakeFromNib() {} 20 | 21 | override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { 22 | let attr = layoutAttributes.copy() as! UICollectionViewLayoutAttributes 23 | 24 | let fittedSize = systemLayoutSizeFitting( 25 | UIView.layoutFittingCompressedSize, 26 | withHorizontalFittingPriority: UILayoutPriority.fittingSizeLevel, 27 | verticalFittingPriority: UILayoutPriority.fittingSizeLevel 28 | ) 29 | attr.frame.size.height = ceil(fittedSize.height) 30 | return attr 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Fields/Cells/FormTextCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormTextCell.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | final class FormTextCell: FormFieldCell, NibLoadableFinalView, NibReusableView { 12 | // UI 13 | @IBOutlet private var titleLabel: UILabel! 14 | @IBOutlet private var valueLabel: UILabel! 15 | } 16 | 17 | extension FormTextCell { 18 | override func postAwakeFromNib() { 19 | super.postAwakeFromNib() 20 | cleanup() 21 | } 22 | 23 | override func prepareForReuse() { 24 | super.prepareForReuse() 25 | cleanup() 26 | } 27 | 28 | func populate(with model: FormTextModel) { 29 | render(model) 30 | } 31 | 32 | override func updateConstraints() { 33 | titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width 34 | valueLabel.preferredMaxLayoutWidth = valueLabel.bounds.width 35 | super.updateConstraints() 36 | } 37 | } 38 | 39 | private extension FormTextCell { 40 | func cleanup() { 41 | titleLabel.text = nil 42 | valueLabel.text = nil 43 | } 44 | 45 | func render(_ model: FormTextModel) { 46 | titleLabel.text = model.title 47 | valueLabel.text = model.value 48 | 49 | model.customSetup(valueLabel) 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /Fields/Cells/FormTextCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 27 | 28 | 29 | 30 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /Fields/Cells/PickerCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PickerCell.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | final class PickerCell: FormFieldCell, NibLoadableFinalView, NibReusableView { 12 | // UI 13 | @IBOutlet private var titleLabel: UILabel! 14 | @IBOutlet private var valueLabel: UILabel! 15 | 16 | private var displayPicker: (PickerCell) -> Void = {_ in} 17 | } 18 | 19 | extension PickerCell { 20 | override func postAwakeFromNib() { 21 | super.postAwakeFromNib() 22 | cleanup() 23 | } 24 | 25 | override func prepareForReuse() { 26 | super.prepareForReuse() 27 | cleanup() 28 | } 29 | 30 | func populate(with model: PickerModel) { 31 | displayPicker = model.displayPicker 32 | render(model) 33 | } 34 | 35 | override func updateConstraints() { 36 | titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width 37 | valueLabel.preferredMaxLayoutWidth = valueLabel.bounds.width 38 | super.updateConstraints() 39 | } 40 | } 41 | 42 | private extension PickerCell { 43 | func cleanup() { 44 | titleLabel.text = nil 45 | valueLabel.text = nil 46 | } 47 | 48 | func render(_ model: PickerModel) { 49 | titleLabel.text = model.title 50 | valueLabel.text = model.valueFormatter(model.value) ?? model.placeholder 51 | } 52 | 53 | @IBAction func showOptions(_ sender: UIButton) { 54 | displayPicker(self) 55 | } 56 | } 57 | 58 | -------------------------------------------------------------------------------- /Fields/Cells/PickerCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 27 | 28 | 29 | 30 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /Fields/Cells/PickerStackCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PickerStackCell.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | final class PickerStackCell: FormFieldCell, NibLoadableFinalView, NibReusableView { 12 | // UI 13 | @IBOutlet private var fieldView: UIView! 14 | @IBOutlet private var titleLabel: UILabel! 15 | @IBOutlet private var valueLabel: UILabel! 16 | @IBOutlet private var textField: UITextField! 17 | @IBOutlet private var stackView: UIStackView! 18 | @IBOutlet private var pickerButton: UIButton! 19 | 20 | @IBOutlet private var textFieldTrailingConstraint: NSLayoutConstraint! 21 | @IBOutlet private var optionsZeroHeightConstraint: NSLayoutConstraint! 22 | 23 | private var displayPicker: (PickerStackCell) -> Void = {_ in} 24 | private var selectedValueAtIndex: (Int?, FormFieldCell, Bool) -> Void = {_, _, _ in} 25 | private var areOptionsExpanded = false 26 | } 27 | 28 | extension PickerStackCell { 29 | override func postAwakeFromNib() { 30 | super.postAwakeFromNib() 31 | cleanup() 32 | applyTheme() 33 | 34 | textField.isUserInteractionEnabled = false 35 | } 36 | 37 | override func prepareForReuse() { 38 | super.prepareForReuse() 39 | cleanup() 40 | } 41 | 42 | func populate(with model: PickerModel) { 43 | areOptionsExpanded = model.isPickerShown 44 | displayPicker = model.displayPicker 45 | selectedValueAtIndex = model.selectedValueAtIndex 46 | 47 | commonRender(model) 48 | render(model) 49 | } 50 | 51 | override func updateConstraints() { 52 | let innerWidth = bounds.width - (layoutMargins.left + layoutMargins.right) 53 | titleLabel.preferredMaxLayoutWidth = innerWidth 54 | 55 | super.updateConstraints() 56 | } 57 | } 58 | 59 | private extension PickerStackCell { 60 | func cleanup() { 61 | titleLabel.text = nil 62 | valueLabel.text = nil 63 | textField.text = nil 64 | } 65 | 66 | func applyTheme() { 67 | textField.tintColor = textField.textColor 68 | } 69 | 70 | func render(_ model: PickerModel) { 71 | titleLabel.text = model.title 72 | 73 | textField.text = model.valueFormatter(model.value) 74 | textField.placeholder = model.placeholder 75 | 76 | if model.isPickerShown { 77 | pickerButton.setImage(UIImage(systemName: "chevron.up.circle"), for: .normal) 78 | } else { 79 | pickerButton.setImage(UIImage(systemName: "chevron.down.circle"), for: .normal) 80 | } 81 | 82 | renderOptions(model, areOptionsExpanded: model.isPickerShown) 83 | 84 | layoutIfNeeded() 85 | } 86 | 87 | func renderOptions(_ model: PickerModel, areOptionsExpanded isExpanded: Bool = false) { 88 | optionsZeroHeightConstraint.isActive = !isExpanded 89 | 90 | stackView.removeAllSubviews() 91 | 92 | model.values.enumerated().forEach { 93 | let item = PickerStackItem.nibInstance 94 | let value = model.valueFormatter($0.element) ?? "--" 95 | 96 | item.populate( 97 | with: value, 98 | isChosen: model.value == $0.element 99 | ) 100 | item.translatesAutoresizingMaskIntoConstraints = false 101 | let index = $0.offset 102 | item.on(.touchUpInside) { 103 | [unowned self] _ in 104 | self.selectedValueAtIndex(index, self, true) 105 | } 106 | 107 | stackView.addArrangedSubview(item) 108 | } 109 | } 110 | 111 | @IBAction func toggleOptions(_ sender: UIButton) { 112 | displayPicker(self) 113 | } 114 | 115 | func updateLayout(animated: Bool = false) { 116 | if !animated { 117 | layoutIfNeeded() 118 | return 119 | } 120 | 121 | UIView.animate(withDuration: 0.3, animations: { 122 | [unowned self] in 123 | self.layoutIfNeeded() 124 | }, completion: { 125 | _ in 126 | 127 | }) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Fields/Cells/PickerStackItem.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class PickerStackItem: UIControl, NibLoadableFinalView { 4 | // UI 5 | @IBOutlet private var nameLabel: UILabel! 6 | @IBOutlet private var chosenIndicatorView: UIImageView! 7 | } 8 | 9 | private extension PickerStackItem { 10 | func applyTheme() { 11 | } 12 | 13 | func cleanup() { 14 | nameLabel.text = nil 15 | chosenIndicatorView.isHidden = true 16 | } 17 | } 18 | 19 | extension PickerStackItem { 20 | override func awakeFromNib() { 21 | super.awakeFromNib() 22 | MainActor.assumeIsolated { 23 | cleanup() 24 | applyTheme() 25 | } 26 | } 27 | 28 | func populate(with string: String, attrString: NSAttributedString? = nil, isChosen: Bool = false) { 29 | if let attrString { 30 | nameLabel.text = attrString.string 31 | nameLabel.attributedText = attrString 32 | } else { 33 | nameLabel.attributedText = nil 34 | nameLabel.text = string 35 | } 36 | chosenIndicatorView.isHidden = !isChosen 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Fields/Cells/PickerStackItem.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 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 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /Fields/Cells/SegmentsCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SegmentsCell.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | final class SegmentsCell: FormFieldCell, NibLoadableFinalView, NibReusableView { 12 | // UI 13 | @IBOutlet private var titleLabel: UILabel! 14 | @IBOutlet private var segmentedControl: UISegmentedControl! 15 | 16 | private var selectedValueAtIndex: (Int?, SegmentsCell, Bool) -> Void = {_, _, _ in} 17 | } 18 | 19 | extension SegmentsCell { 20 | override func postAwakeFromNib() { 21 | super.postAwakeFromNib() 22 | cleanup() 23 | } 24 | 25 | override func prepareForReuse() { 26 | super.prepareForReuse() 27 | cleanup() 28 | } 29 | 30 | func populate(with model: PickerModel) { 31 | selectedValueAtIndex = model.selectedValueAtIndex 32 | 33 | render(model) 34 | } 35 | 36 | override func updateConstraints() { 37 | titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width 38 | 39 | super.updateConstraints() 40 | } 41 | } 42 | 43 | private extension SegmentsCell { 44 | func cleanup() { 45 | titleLabel.text = nil 46 | segmentedControl.removeAllSegments() 47 | } 48 | 49 | func render(_ model: PickerModel) { 50 | titleLabel.text = model.title 51 | 52 | for (index, v) in model.values.enumerated() { 53 | let s = model.valueFormatter(v) 54 | segmentedControl.insertSegment(withTitle: s, at: index, animated: false) 55 | } 56 | if let v = model.value, let index = model.values.firstIndex(of: v) { 57 | segmentedControl.selectedSegmentIndex = index 58 | } 59 | } 60 | 61 | @IBAction func changeSelection(_ sender: UISegmentedControl) { 62 | selectedValueAtIndex(sender.selectedSegmentIndex, self, true) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Fields/Cells/SegmentsCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 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 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /Fields/Cells/SelfSizingHeightCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelfSizingHeightCell.swift 3 | // Fields 4 | // 5 | // Copyright © 2018 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | class SelfSizingHeightCell: UICollectionViewCell { 12 | override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { 13 | let attr = layoutAttributes.copy() as! UICollectionViewLayoutAttributes 14 | 15 | let fittedSize = systemLayoutSizeFitting( 16 | UIView.layoutFittingCompressedSize, 17 | withHorizontalFittingPriority: UILayoutPriority.fittingSizeLevel, 18 | verticalFittingPriority: UILayoutPriority.fittingSizeLevel 19 | ) 20 | attr.frame.size.height = ceil(fittedSize.height) 21 | return attr 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Fields/Cells/SeparatorLineView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class SeparatorLineView: UICollectionReusableView, ReusableView { 4 | static let kind = "SeparatorLineView" 5 | 6 | override init(frame: CGRect) { 7 | super.init(frame: frame) 8 | backgroundColor = .separator 9 | } 10 | 11 | required init?(coder: NSCoder) { 12 | fatalError("init(coder:) has not been implemented") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Fields/Cells/TextFieldCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextFieldCell.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | final class TextFieldCell: FormFieldCell, NibLoadableFinalView, NibReusableView { 12 | // UI 13 | @IBOutlet private var titleLabel: UILabel! 14 | @IBOutlet private var textField: UITextField! 15 | 16 | private var valueChanged: (String?, TextFieldCell) -> Void = {_, _ in} 17 | } 18 | 19 | extension TextFieldCell { 20 | override func postAwakeFromNib() { 21 | super.postAwakeFromNib() 22 | cleanup() 23 | 24 | textField.addTarget(self, action: #selector(editText), for: .editingDidEnd) 25 | textField.addTarget(self, action: #selector(editText), for: .editingDidEndOnExit) 26 | } 27 | 28 | override func prepareForReuse() { 29 | super.prepareForReuse() 30 | cleanup() 31 | } 32 | 33 | func populate(with model: TextFieldModel) { 34 | valueChanged = model.valueChanged 35 | render(model) 36 | } 37 | 38 | override func updateConstraints() { 39 | titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width 40 | super.updateConstraints() 41 | } 42 | 43 | override func didMoveToSuperview() { 44 | super.didMoveToSuperview() 45 | 46 | textField?.delegate = fieldsController 47 | } 48 | } 49 | 50 | private extension TextFieldCell { 51 | func cleanup() { 52 | textField.text = nil 53 | textField.placeholder = nil 54 | titleLabel.text = nil 55 | valueChanged = {_, _ in} 56 | } 57 | 58 | func render(_ model: TextFieldModel) { 59 | titleLabel.text = model.title 60 | textField.text = model.value 61 | textField.placeholder = model.placeholder 62 | model.customSetup( textField ) 63 | } 64 | 65 | @objc func editText(_ sender: UITextField) { 66 | valueChanged(sender.text, self) 67 | } 68 | } 69 | 70 | -------------------------------------------------------------------------------- /Fields/Cells/TextFieldCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 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 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /Fields/Cells/TextViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextViewCell.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | final class TextViewCell: FormFieldCell, NibReusableView { 12 | // UI 13 | @IBOutlet private var titleLabel: UILabel! 14 | @IBOutlet private var textView: UITextView! 15 | 16 | @IBOutlet private var heightConstraint: NSLayoutConstraint! 17 | 18 | private var valueChanged: (String?, TextViewCell) -> Void = {_, _ in} 19 | } 20 | 21 | extension TextViewCell { 22 | override func postAwakeFromNib() { 23 | super.postAwakeFromNib() 24 | cleanup() 25 | 26 | textView.delegate = self 27 | } 28 | 29 | override func prepareForReuse() { 30 | super.prepareForReuse() 31 | cleanup() 32 | } 33 | 34 | func populate(with model: TextViewModel) { 35 | valueChanged = model.valueChanged 36 | render(model) 37 | } 38 | 39 | override func updateConstraints() { 40 | titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width 41 | 42 | super.updateConstraints() 43 | } 44 | } 45 | 46 | private extension TextViewCell { 47 | func cleanup() { 48 | textView.text = nil 49 | titleLabel.text = nil 50 | } 51 | 52 | func render(_ model: TextViewModel) { 53 | heightConstraint.constant = model.minimalHeight 54 | 55 | titleLabel.text = model.title 56 | textView.text = model.value 57 | 58 | model.customSetup( textView ) 59 | } 60 | } 61 | 62 | extension TextViewCell: UITextViewDelegate { 63 | func textViewDidBeginEditing(_ textView: UITextView) { 64 | fieldsCollectionController?.textViewDidBeginEditing(textView) 65 | } 66 | 67 | func textViewDidChange(_ sender: UITextView) { 68 | valueChanged(sender.text, self) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Fields/Cells/TextViewCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 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 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /Fields/Cells/ToggleCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToggleCell.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | final class ToggleCell: FormFieldCell, NibLoadableFinalView, NibReusableView { 12 | // UI 13 | @IBOutlet private var titleLabel: UILabel! 14 | @IBOutlet private var toggle: UISwitch! 15 | 16 | private var valueChanged: (Bool, ToggleCell) -> Void = {_, _ in} 17 | } 18 | 19 | extension ToggleCell { 20 | override func postAwakeFromNib() { 21 | super.postAwakeFromNib() 22 | cleanup() 23 | 24 | toggle.addTarget(self, action: #selector(toggled), for: .valueChanged) 25 | } 26 | 27 | override func prepareForReuse() { 28 | super.prepareForReuse() 29 | cleanup() 30 | } 31 | 32 | func populate(with model: ToggleModel) { 33 | valueChanged = model.valueChanged 34 | render(model) 35 | } 36 | 37 | override func updateConstraints() { 38 | titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width 39 | super.updateConstraints() 40 | } 41 | } 42 | 43 | private extension ToggleCell { 44 | func cleanup() { 45 | toggle.isOn = false 46 | titleLabel.text = nil 47 | } 48 | 49 | func render(_ model: ToggleModel) { 50 | titleLabel.text = model.title 51 | toggle.isOn = model.value 52 | model.customSetup( toggle ) 53 | } 54 | 55 | @objc func toggled(_ sender: UISwitch) { 56 | valueChanged(sender.isOn, self) 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /Fields/Cells/ToggleCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 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 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /Fields/Controllers/PickerOptionTextCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PickerOptionTextCell.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | class PickerOptionTextCell: UICollectionViewCell, NibReusableView { 12 | // UI 13 | @IBOutlet private var valueLabel: UILabel! 14 | } 15 | 16 | extension PickerOptionTextCell { 17 | override func awakeFromNib() { 18 | super.awakeFromNib() 19 | MainActor.assumeIsolated { 20 | selectedBackgroundView = { 21 | let v = UIView(frame: .zero) 22 | v.backgroundColor = .white 23 | return v 24 | }() 25 | 26 | cleanup() 27 | } 28 | } 29 | 30 | override func prepareForReuse() { 31 | super.prepareForReuse() 32 | cleanup() 33 | } 34 | 35 | func populate(with text: String) { 36 | valueLabel.text = text 37 | } 38 | 39 | override func updateConstraints() { 40 | valueLabel.preferredMaxLayoutWidth = valueLabel.bounds.width 41 | super.updateConstraints() 42 | } 43 | } 44 | 45 | private extension PickerOptionTextCell { 46 | func cleanup() { 47 | valueLabel.text = nil 48 | } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /Fields/Controllers/PickerOptionTextCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /Fields/Controllers/PickerOptionsListController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PickerOptionsListController.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | class PickerOptionsListController: UIViewController { 12 | 13 | private(set) var collectionView: UICollectionView! 14 | private var layout: UICollectionViewLayout 15 | private var provider: PickerOptionsProvider 16 | private var fieldTitle: String? 17 | 18 | init(layout: UICollectionViewLayout = UICollectionViewFlowLayout(), title: String? = nil, provider: PickerOptionsProvider) { 19 | self.layout = layout 20 | self.provider = provider 21 | self.fieldTitle = title ?? provider.model.title 22 | super.init(nibName: nil, bundle: nil) 23 | } 24 | 25 | required init?(coder aDecoder: NSCoder) { 26 | preconditionFailure("init(coder:) has not been implemented") 27 | } 28 | 29 | // View lifecycle 30 | 31 | override func loadView() { 32 | super.loadView() 33 | loadCollectionView() 34 | } 35 | 36 | override func viewDidLoad() { 37 | super.viewDidLoad() 38 | 39 | collectionView.register(PickerOptionTextCell.self) 40 | title = fieldTitle 41 | 42 | collectionView.delegate = provider 43 | collectionView.dataSource = provider 44 | 45 | applyTheme() 46 | } 47 | 48 | override func viewWillAppear(_ animated: Bool) { 49 | super.viewWillAppear(animated) 50 | 51 | preselect() 52 | } 53 | 54 | // Configuration 55 | 56 | private func preselect() { 57 | guard 58 | let value = provider.model.value, 59 | let index = provider.model.values.firstIndex(of: value) 60 | else { return } 61 | 62 | let indexPath = IndexPath(item: index, section: 0) 63 | collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically) 64 | } 65 | } 66 | 67 | private extension PickerOptionsListController { 68 | func applyTheme() { 69 | view.backgroundColor = UIColor(hex: "EBEBEB") 70 | } 71 | 72 | func loadCollectionView() { 73 | let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) 74 | cv.translatesAutoresizingMaskIntoConstraints = false 75 | 76 | cv.backgroundColor = view.backgroundColor 77 | 78 | view.addSubview(cv) 79 | cv.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true 80 | cv.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true 81 | cv.topAnchor.constraint(equalTo: view.topAnchor).isActive = true 82 | cv.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true 83 | 84 | self.collectionView = cv 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Fields/Controllers/PickerOptionsProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PickerOptionsProvider.swift 3 | // Fields 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | /// A derivative of the PickerModel, acts as data source and delegate for the PickerOptionsController. 12 | /// 13 | /// Can be used for values of any data type 14 | final class PickerOptionsProvider: NSObject, UICollectionViewDataSource, UICollectionViewDelegate { 15 | 16 | /// PickerModel, to fetch list of values from 17 | private(set) var model: PickerModel 18 | 19 | /// PickerCell instance which initiated the display of the options list 20 | private var pickerCell: FormFieldCell 21 | 22 | 23 | 24 | init(for cell: FormFieldCell, 25 | with model: PickerModel 26 | ){ 27 | self.pickerCell = cell 28 | self.model = model 29 | 30 | super.init() 31 | } 32 | 33 | // MARK: UICollectionViewDataSource 34 | 35 | func numberOfSections(in collectionView: UICollectionView) -> Int { 36 | return 1 37 | } 38 | 39 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 40 | return model.values.count 41 | } 42 | 43 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 44 | 45 | let cell: Cell = collectionView.dequeueReusableCell(forIndexPath: indexPath) 46 | 47 | switch cell { 48 | case let cell as PickerOptionTextCell: 49 | let value = model.values[indexPath.item] 50 | let s = model.valueFormatter(value) ?? "--" 51 | cell.populate(with: s) 52 | default: 53 | preconditionFailure("Unhandled PickerOption*Cell: \( cell.self )") 54 | } 55 | 56 | return cell 57 | } 58 | 59 | // MARK: UICollectionViewDelegate 60 | 61 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 62 | model.selectedValueAtIndex(indexPath.item, pickerCell, true) 63 | } 64 | } 65 | 66 | -------------------------------------------------------------------------------- /Fields/FieldsCollectionController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fields 3 | // 4 | // Copyright © 2019 Radiant Tap 5 | // MIT License · http://choosealicense.com/licenses/mit/ 6 | // 7 | 8 | import UIKit 9 | 10 | class FieldsCollectionController: FieldsController { 11 | private(set) var collectionView: UICollectionView! 12 | 13 | // View lifecycle 14 | 15 | override func loadView() { 16 | super.loadView() 17 | loadCollectionView() 18 | } 19 | 20 | var dataSource: FieldsDataSourceable? { 21 | didSet { 22 | if !isViewLoaded { return } 23 | prepareDataSource() 24 | } 25 | } 26 | 27 | override func viewDidLoad() { 28 | super.viewDidLoad() 29 | 30 | prepareDataSource() 31 | } 32 | 33 | // Override these methods, if you need to change default behavior 34 | 35 | override func keyboardWillShow(notification kn: KeyboardNotification) { 36 | let diff = max(0, kn.endFrame.height - view.safeAreaInsets.bottom) 37 | collectionView.contentInset.bottom = diff 38 | } 39 | 40 | override func keyboardWillHide(notification kn: KeyboardNotification) { 41 | collectionView.contentInset.bottom = 0 42 | } 43 | 44 | override func contentSizeCategoryChanged(notification kn: ContentSizeCategoryNotification) { 45 | collectionView.reloadData() 46 | } 47 | } 48 | 49 | private extension FieldsCollectionController { 50 | func loadCollectionView() { 51 | let cv = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) 52 | cv.translatesAutoresizingMaskIntoConstraints = false 53 | 54 | cv.backgroundColor = .clear 55 | 56 | view.addSubview(cv) 57 | cv.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true 58 | cv.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true 59 | cv.topAnchor.constraint(equalTo: view.topAnchor).isActive = true 60 | cv.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true 61 | 62 | self.collectionView = cv 63 | } 64 | 65 | func prepareDataSource() { 66 | collectionView.delegate = nil 67 | collectionView.dataSource = nil 68 | 69 | dataSource?.controller = self 70 | } 71 | } 72 | 73 | extension FieldsCollectionController: UITextViewDelegate { 74 | func textViewDidBeginEditing(_ textView: UITextView) { 75 | guard 76 | let cell: TextViewCell = textView.containingCell(), 77 | let indexPath = collectionView.indexPath(for: cell), 78 | let attr = collectionView.layoutAttributesForItem(at: indexPath) 79 | else { return } 80 | 81 | collectionView.scrollRectToVisible(attr.frame, animated: true) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Fields/FieldsController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fields 3 | // 4 | // Copyright © 2019 Radiant Tap 5 | // MIT License · http://choosealicense.com/licenses/mit/ 6 | // 7 | 8 | import UIKit 9 | 10 | class FieldsController: UIViewController { 11 | 12 | // Notification handlers 13 | private var tokenKeyboardWillShow: NotificationToken? 14 | private var tokenKeyboardWillHide: NotificationToken? 15 | private var tokenContentSizeCategoryChanged: NotificationToken? 16 | 17 | // Entry point for DataSource object to ask VC to redraw itself 18 | 19 | func renderContentUpdates() { 20 | if !isViewLoaded { return } 21 | 22 | } 23 | 24 | // Override these methods, if you need to change default behavior 25 | 26 | private(set) var originalAdditionalSafeAreaInsets: UIEdgeInsets = .zero 27 | private(set) var originalViewSafeAreaInsets: UIEdgeInsets = .zero 28 | private(set) var keyboardAdditionalSafeAreaInsets: UIEdgeInsets = .zero 29 | 30 | func keyboardWillShow(notification kn: KeyboardNotification) { 31 | // Keyboard appears on top of entire UI. So the 'endFrame' we get here includes bottom safeAreaInsets already. 32 | // Our embedded VC's UI is laid-out obeying safeAreaInsets of the device, 33 | // thus we must subtract bottom part of safeAreaInsets from reported keyboard height. 34 | // That will give us value to use for local (embedded) VC.view 35 | let kb = kn.endFrame.height - originalViewSafeAreaInsets.bottom 36 | keyboardAdditionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: kb, right: 0) 37 | // Now, set VC additionalSafeAreaInsets to use the maximum values between its original or keyboard 38 | additionalSafeAreaInsets = keyboardAdditionalSafeAreaInsets.union(originalAdditionalSafeAreaInsets) 39 | } 40 | 41 | func keyboardWillHide(notification kn: KeyboardNotification) { 42 | additionalSafeAreaInsets = originalAdditionalSafeAreaInsets 43 | } 44 | 45 | func contentSizeCategoryChanged(notification kn: ContentSizeCategoryNotification) { 46 | } 47 | } 48 | 49 | extension FieldsController { 50 | override func viewDidLoad() { 51 | super.viewDidLoad() 52 | 53 | setupKeyboardNotificationHandlers() 54 | } 55 | 56 | override func viewDidAppear(_ animated: Bool) { 57 | super.viewDidAppear(animated) 58 | 59 | originalViewSafeAreaInsets = view.safeAreaInsets 60 | originalAdditionalSafeAreaInsets = additionalSafeAreaInsets 61 | } 62 | } 63 | 64 | private extension FieldsController { 65 | func setupKeyboardNotificationHandlers() { 66 | let nc = NotificationCenter.default 67 | 68 | tokenKeyboardWillShow = nc.addObserver(forConvertedDescriptor: KeyboardNotification.willShow, queue: .main) { 69 | [weak self] kn in 70 | Task { @MainActor in 71 | self?.keyboardWillShow(notification: kn) 72 | } 73 | } 74 | 75 | tokenKeyboardWillHide = nc.addObserver(forConvertedDescriptor: KeyboardNotification.willHide, queue: .main) { 76 | [weak self] kn in 77 | Task { @MainActor in 78 | self?.keyboardWillHide(notification: kn) 79 | } 80 | } 81 | 82 | tokenContentSizeCategoryChanged = nc.addObserver(forConvertedDescriptor: ContentSizeCategoryNotification.didChange, queue: .main) { 83 | [weak self] kn in 84 | Task { @MainActor in 85 | self?.contentSizeCategoryChanged(notification: kn) 86 | } 87 | } 88 | } 89 | } 90 | 91 | extension FieldsController: UITextFieldDelegate { 92 | @objc func textFieldShouldReturn(_ textField: UITextField) -> Bool { 93 | textField.resignFirstResponder() 94 | return false 95 | } 96 | 97 | @objc func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { 98 | return true 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Fields/FieldsDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fields 3 | // 4 | // Copyright © 2021 Radiant Tap 5 | // MIT License · http://choosealicense.com/licenses/mit/ 6 | // 7 | 8 | import UIKit 9 | 10 | 11 | 12 | @MainActor 13 | protocol FieldsDataSourceable: AnyObject { 14 | var controller: FieldsCollectionController? { get set } 15 | } 16 | 17 | 18 | /// Base class which defines layout and handles diffable data source for `UICollectionView`. 19 | /// 20 | /// Make sure to subclass this file and override the following methods: 21 | /// 22 | /// - `registerReusableElements(for:)` 23 | /// - `populateSnapshot(flowIdentifier:)` 24 | /// 25 | /// You **must** call `super` when overriding these methods. 26 | /// 27 | @MainActor 28 | class FieldsDataSource: NSObject, FieldsDataSourceable { 29 | typealias GridSource = UICollectionViewDiffableDataSource 30 | 31 | 32 | // Dependencies 33 | weak var controller: FieldsCollectionController? { 34 | didSet { prepareView() } 35 | } 36 | 37 | var estimatedFieldHeight: CGFloat = 66 38 | var interSectionVerticalSpacing: CGFloat = 0 39 | 40 | var areSeparatorsEnabled = false 41 | 42 | /// Map of unique `FieldId` (raw, String) values versus cell registration for each field. 43 | /// 44 | /// This is populated in `registerReusableElements(for:)`. 45 | var cellRegistrations: [FieldModel.ID: UICollectionView.CellRegistration] = [:] 46 | 47 | /// Map of `elementKind` versus supplementary view registrations. 48 | /// 49 | /// This is populated in `registerReusableElements(for:)`. 50 | var supplementaryRegistrations: [String: UICollectionView.SupplementaryRegistration] = [:] 51 | 52 | // Local data model 53 | 54 | private(set) var gridSource: GridSource! 55 | 56 | // MARK: Override points 57 | 58 | @objc func prepareView(flowIdentifier fid: String = UUID().uuidString) { 59 | guard let cv = controller?.collectionView else { return } 60 | 61 | registerReusableElements(for: cv) 62 | 63 | let layout = createLayout() 64 | cv.setCollectionViewLayout(layout, animated: false) 65 | configureCVDataSource(for: cv, flowIdentifier: fid) 66 | } 67 | 68 | /// This is where you register your custom `UICVCell` and `UICVReusableView` subclasses. 69 | @objc func registerReusableElements(for cv: UICollectionView) { 70 | supplementaryRegistrations[SeparatorLineView.kind] = UICollectionView.SupplementaryRegistration(elementKind: SeparatorLineView.kind) { _, _, _ in } 71 | } 72 | 73 | func cell(collectionView: UICollectionView, indexPath: IndexPath, item: FieldModel) -> UICollectionViewCell { 74 | preconditionFailure() 75 | } 76 | 77 | final func cell(collectionView: UICollectionView, indexPath: IndexPath, itemIdentifier: FieldModel.ID) -> UICollectionViewCell { 78 | guard let cellReg = cellRegistrations[itemIdentifier] else { 79 | preconditionFailure("Unknown cell model") 80 | } 81 | return collectionView.dequeueConfiguredReusableCell(using: cellReg, for: indexPath, item: itemIdentifier) 82 | } 83 | 84 | /// `UICVReusableView` factory. 85 | final func supplementary(collectionView: UICollectionView, kind: String, indexPath: IndexPath) -> UICollectionReusableView { 86 | guard let supplReg = supplementaryRegistrations[kind] else { 87 | preconditionFailure("Unexpected supplementary kind: \( kind )") 88 | } 89 | 90 | return collectionView.dequeueConfiguredReusableSupplementary(using: supplReg, for: indexPath) 91 | } 92 | 93 | /// Diffable data source for the UICV, using same signature as `GridSource` above: 94 | typealias Snapshot = NSDiffableDataSourceSnapshot 95 | 96 | /// `Snapshot` factory 97 | func populateSnapshot(flowIdentifier fid: String) -> Snapshot { 98 | preconditionFailure("Must override this method and return properly populated Snapshot.") 99 | } 100 | 101 | /// By default, returns empty array. 102 | /// 103 | /// Override this and setup header / footer, per section. 104 | @objc func layoutSectionSupplementaryItems(atIndex sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> [NSCollectionLayoutBoundarySupplementaryItem] { 105 | return [] 106 | } 107 | 108 | /// By default, returns empty array. 109 | /// 110 | /// Override this and setup *global* header / footer 111 | @objc func layoutGlobalSupplementaryItems() -> [NSCollectionLayoutBoundarySupplementaryItem] { 112 | return [] 113 | } 114 | 115 | /// Implement any desired custom configuration for the given section 116 | /// 117 | /// By default, does nothing. 118 | func customConfigure(section: NSCollectionLayoutSection, atIndex sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) { 119 | } 120 | 121 | /// If not overriden, it will create full-width form field layout with estimated height of `estimatedFieldHeight`. Override `layoutSectionSupplementaryItems(atIndex:layoutEnvironment:)` to declare header/footer for section at supplied index. 122 | @objc func createLayout() -> UICollectionViewLayout { 123 | let config = UICollectionViewCompositionalLayoutConfiguration() 124 | config.interSectionSpacing = interSectionVerticalSpacing 125 | config.boundarySupplementaryItems = layoutGlobalSupplementaryItems() 126 | 127 | let layout = UICollectionViewCompositionalLayout( 128 | sectionProvider: ({ [weak self] in self?.createLayoutSection(atIndex: $0, layoutEnvironment: $1) }), 129 | configuration: config 130 | ) 131 | 132 | return layout 133 | } 134 | 135 | /// If not override, will create simple section 136 | @objc func createLayoutSection(atIndex sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? { 137 | var itemSupplementaryItems: [NSCollectionLayoutSupplementaryItem] = [] 138 | if areSeparatorsEnabled { 139 | let lineAnchor = NSCollectionLayoutAnchor(edges: [.bottom, .trailing]) 140 | let lineSize = NSCollectionLayoutSize( 141 | widthDimension: .fractionalWidth(1), 142 | heightDimension: .absolute(1) 143 | ) 144 | let line = NSCollectionLayoutSupplementaryItem( 145 | layoutSize: lineSize, 146 | elementKind: SeparatorLineView.kind, 147 | containerAnchor: lineAnchor) 148 | 149 | itemSupplementaryItems = [line] 150 | } 151 | 152 | let item = NSCollectionLayoutItem( 153 | layoutSize: NSCollectionLayoutSize( 154 | widthDimension: .fractionalWidth( 1.0 ), 155 | heightDimension: .estimated(estimatedFieldHeight) 156 | ), 157 | supplementaryItems: itemSupplementaryItems 158 | ) 159 | 160 | let group = NSCollectionLayoutGroup.vertical( 161 | layoutSize: NSCollectionLayoutSize( 162 | widthDimension: .fractionalWidth(1.0), 163 | heightDimension: .estimated(estimatedFieldHeight) 164 | ), 165 | subitems: [item] 166 | ) 167 | 168 | let section = NSCollectionLayoutSection(group: group) 169 | section.boundarySupplementaryItems = layoutSectionSupplementaryItems(atIndex: sectionIndex, layoutEnvironment: layoutEnvironment) 170 | 171 | customConfigure(section: section, atIndex: sectionIndex, layoutEnvironment: layoutEnvironment) 172 | return section 173 | } 174 | 175 | // MARK: Utility 176 | 177 | var currentSnapshot: Snapshot { 178 | gridSource.snapshot() 179 | } 180 | 181 | func render(_ snapshot: Snapshot, animated: Bool = true) { 182 | if controller == nil { return } 183 | 184 | gridSource.apply(snapshot, animatingDifferences: animated) 185 | } 186 | } 187 | 188 | private extension FieldsDataSource { 189 | func configureCVDataSource(for cv: UICollectionView, flowIdentifier fid: String) { 190 | gridSource = GridSource( 191 | collectionView: cv, 192 | cellProvider: { [unowned self] cv, indexPath, fieldModelId in 193 | return self.cell(collectionView: cv, indexPath: indexPath, itemIdentifier: fieldModelId) 194 | } 195 | ) 196 | 197 | gridSource.supplementaryViewProvider = { 198 | [unowned self] cv, kind, indexPath in 199 | return self.supplementary(collectionView: cv, kind: kind, indexPath: indexPath) 200 | } 201 | 202 | let snapshot = populateSnapshot(flowIdentifier: fid) 203 | render(snapshot, animated: false) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /Fields/UIResponder-FieldsDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fields 3 | // 4 | // Copyright © 2019 Radiant Tap 5 | // MIT License · http://choosealicense.com/licenses/mit/ 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIResponder { 11 | var containingViewController: UIViewController? { 12 | if let c = self as? UIViewController { 13 | return c 14 | } 15 | 16 | if let c = next as? UIViewController { 17 | return c 18 | } 19 | 20 | return next?.containingViewController 21 | } 22 | 23 | var fieldsController: FieldsController? { 24 | if let c = self as? FieldsController { 25 | return c 26 | } 27 | 28 | if let c = next as? FieldsController { 29 | return c 30 | } 31 | 32 | return next?.fieldsController 33 | } 34 | 35 | var fieldsCollectionController: FieldsCollectionController? { 36 | if let c = self as? FieldsCollectionController { 37 | return c 38 | } 39 | 40 | if let c = next as? FieldsCollectionController { 41 | return c 42 | } 43 | 44 | return next?.fieldsCollectionController 45 | } 46 | 47 | func containingCell() -> T? { 48 | if let c = self as? T { 49 | return c 50 | } 51 | 52 | if let c = next as? T { 53 | return c 54 | } 55 | 56 | return next?.containingCell() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Aleksandar Vacić 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![platforms: iOS](https://img.shields.io/badge/platform-iOS-blue.svg) 2 | [![](https://img.shields.io/github/license/radianttap/Fields.svg)](https://github.com/radianttap/Coordinator/blob/master/LICENSE) 3 | ![](https://img.shields.io/badge/swift-6-223344.svg?logo=swift&labelColor=FA7343&logoColor=white) 4 | 5 | # Fields v2 6 | 7 | Good, solid base to build custom forms in iOS apps, using [Compositional Layout](https://developer.apple.com/documentation/uikit/uicollectionviewcompositionallayout). 8 | 9 | This is *not a library, nor framework*. It will never be a CocoaPod, Carthage or whatever package. Every form is different so you take this lot and adjust it to suit each specific case. 10 | 11 | Each cell is self-sizing, implemented through `FormFieldCell`, which is base cell for all other specific cells. *It is expected that you use .xib for cell design and use qualified Auto Layout constraints so that self-sizing is possible.* 12 | 13 | Each form field is implemented by pairing `Model` and `Cell` instance. Models are distinguished by `id` property; value of this property should be unique across all fields. Easiest way to implement this is with `String` enum. 14 | 15 | The most trivial cell model is `FieldModel` itself which only has one property, previously mentioned `id`. All other models, have specific additional properties that directly map into cell display. Things like `title`, current field `value`, optional hints and error messages etc. 16 | 17 | Model properties directly tie-in with Cell design and layout. 18 | 19 | ### Available Cells & Models 20 | 21 | Each supported form field has a [reference Cell](Fields/Cells) implementation and its accompanying [ViewModel](Fields/CellModels). 22 | 23 | * `FormTextCell` + `FormTextModel` – for static text, with support for multiple lines. 24 | * `TextFieldCell` + `TextFieldModel` – classic text field input 25 | * `TextViewCell` + `TextViewModel` – cell with internal `UITextView`, for large multi-line text 26 | * `ToggleCell` + `ToggleModel` – for boolean flags 27 | * `FormButtonCell` + `FormButtonModel` – models submit and other buttons 28 | * `DatePickerCell` + `DatePickerModel` – shows `UIDatePicker` as “keyboard” for the field. 29 | * `PickerCell` + `PickerModel` – when you need to show a larger set of items and allow customer to choose one. It has a reference option cell implementation, custom UIVC and DataProvider types. 30 | * `PickerStackCell` + `PickerModel` – closest we can get to drop-down picker. 31 | * `SegmentsCell` + `PickerModel` - when you only have few options to choose from and want to display them using `UISegmentedControl`. 32 | 33 | ### Sections 34 | 35 | Fields can be grouped into `FieldSection` arrays, where each section is defined by custom String `id` and a set of accompanying fields. 36 | 37 | You can also specify custom header and footer text for the section and adjust their design + model, as you see fit. 38 | 39 | Both of these are subclasses of `FieldSupplementaryView` which implements self-sizing support. 40 | 41 | ### Controllers 42 | 43 | The base UIVC class is `FieldsController`, which you can use **if** you want to manually add the fields inside, say `UIScrollView`, instead of using Collection View. 44 | 45 | (Example: ForgotPass screen in the demo app) 46 | 47 | Its subclass, `FieldsCollectionController`, is much more interesting as it builds an UICV instance to which you will add your cells. 48 | 49 | (Examples: Login and Register screens in the demo app) 50 | 51 | ## Usage 52 | 53 | > The best way is learn how it works is to look at the demo app. It has 3 different forms and they illustrate typical uses. 54 | 55 | For each form, you should subclass one of the said two controllers, then add another class which will act as DataSource for it. 56 | 57 | For example, `LoginController` in the demo app subclasses `FieldsCollectionController`. It uses `LoginDataSource` as the `UICollectionViewDataSource`. 58 | 59 | For the minimal setup, you don’t need to use sections, you can use just fields. Thus all you need is an array of `[FieldModel]` instances + a declaration of unique `id` values for each field. An enum is just fine: 60 | 61 | ```swift 62 | enum FieldId: String { 63 | case info 64 | case username 65 | case password 66 | case forgotpassword 67 | case submit 68 | } 69 | ``` 70 | 71 | Now, you populate `fields` array with specific Model instance for those fields. Here’s an example of TextFieldModel: 72 | 73 | ```swift 74 | let model = TextFieldModel( 75 | id: FieldId.username.rawValue, 76 | title: NSLocalizedString("Username", comment: ""), 77 | value: user?.username 78 | ) 79 | model.customSetup = { textField in 80 | textField.textContentType = .username 81 | } 82 | model.valueChanged = { 83 | [weak self] string, _ in 84 | 85 | self?.user?.username = string 86 | model.value = string 87 | } 88 | ``` 89 | 90 | This illustrates general idea: 91 | 92 | 1. Setup basic stuff, like title of the field and current value to show. 93 | 2. Specify custom design and behavior – in this case just the `UITextField` is exposed but you can alter this into whatever you want to expose. 94 | 3. Specify handler which is called from the `TextFieldCell`, when the value editing is done. This closure updates actual model objects of your app (like `User`). 95 | 96 | `User` is actual data model type you use in the app. `TextFieldModel` is _ViewModel_ derivative of `User`, custom tailored for the `TextFieldCell`. 97 | 98 | ### Form setup 99 | 100 | The most important aspect is the `registerReusableElements(for:)` method which registers cell/supplementary for each `FieldId` value. This uses [modern diffable data source implementation](https://developer.apple.com/videos/play/wwdc2021/10252/) which is available starting with iOS 15. 101 | 102 | Next you need to build the local data source, which is done in the `prepareFields()` method. Using just fields or both section and fields, you instantiate field models and saved them to a dictionary + their IDs in an array to maintain the order. 103 | 104 | Lastly, you need to override `populateSnapshot(flowIdentifier:) -> Snapshot` where you take the mentioned structure and build actual snapshot which will be render by calling `renderContents(_:,animated:)` method. 105 | 106 | You’ll notice `snapshot.reconfigureItems(fieldIds)` which is telling UIKit to re-layout / re-populate entire form, as needed. 107 | 108 | ## License 109 | 110 | [MIT](https://choosealicense.com/licenses/mit/), as usual. 111 | 112 | ## Give back 113 | 114 | If you found this code useful, please consider [buying me a coffee](https://www.buymeacoffee.com/radianttap) or two. ☕️😋 115 | -------------------------------------------------------------------------------- /Vendor/Controllers/Embeddable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Embeddable.swift 3 | // Radiant Tap Essentials 4 | // https://github.com/radianttap/swift-essentials 5 | // 6 | // Copyright © 2016 Radiant Tap 7 | // MIT License · http://choosealicense.com/licenses/mit/ 8 | 9 | import UIKit 10 | 11 | extension UIViewController { 12 | /// (view, parentView, useMargins) -> Void 13 | public typealias LayoutBlock = (UIView, UIView, Bool) -> Void 14 | 15 | /// Embeds the `view` of the given UIViewController into supplied `parentView` (or into `self.view`, if `nil`). 16 | /// 17 | /// Default value of `LayoutBlock` aligns the embedded `view` with the edges of the `parentView`, using priority=999 for the bottom and trailing constraints). 18 | /// This helps to avoid auto-layout issues when embedding into zero-width or height container views. 19 | public func embed(controller vc: T, into parentView: UIView?, layoutRespectingMargins: Bool = false, layout: LayoutBlock = { 20 | v, pv, layoutRespectingMargins in 21 | 22 | if layoutRespectingMargins { 23 | let constraints: [NSLayoutConstraint] = [ 24 | v.topAnchor.constraint(equalTo: pv.layoutMarginsGuide.topAnchor), 25 | v.leadingAnchor.constraint(equalTo: pv.layoutMarginsGuide.leadingAnchor), 26 | { 27 | let lc = v.bottomAnchor.constraint(equalTo: pv.layoutMarginsGuide.bottomAnchor) 28 | lc.priority = UILayoutPriority(rawValue: 999) 29 | return lc 30 | }(), 31 | { 32 | let lc = v.trailingAnchor.constraint(equalTo: pv.layoutMarginsGuide.trailingAnchor) 33 | lc.priority = UILayoutPriority(rawValue: 999) 34 | return lc 35 | }() 36 | ] 37 | constraints.forEach { $0.isActive = true } 38 | 39 | } else { 40 | let constraints: [NSLayoutConstraint] = [ 41 | v.topAnchor.constraint(equalTo: pv.topAnchor), 42 | v.leadingAnchor.constraint(equalTo: pv.leadingAnchor), 43 | { 44 | let lc = v.bottomAnchor.constraint(equalTo: pv.bottomAnchor) 45 | lc.priority = UILayoutPriority(rawValue: 999) 46 | return lc 47 | }(), 48 | { 49 | let lc = v.trailingAnchor.constraint(equalTo: pv.trailingAnchor) 50 | lc.priority = UILayoutPriority(rawValue: 999) 51 | return lc 52 | }() 53 | ] 54 | constraints.forEach { $0.isActive = true } 55 | } 56 | }) 57 | where T: UIViewController 58 | { 59 | let container = parentView ?? self.view! 60 | 61 | addChild(vc) 62 | container.addSubview(vc.view) 63 | vc.view.translatesAutoresizingMaskIntoConstraints = false 64 | layout(vc.view, container, layoutRespectingMargins) 65 | vc.didMove(toParent: self) 66 | 67 | // Note: after this, save the controller reference 68 | // somewhere in calling scope 69 | } 70 | 71 | public func unembed(controller: UIViewController?) { 72 | guard let controller = controller else { return } 73 | 74 | controller.willMove(toParent: nil) 75 | if controller.isViewLoaded { 76 | controller.view.removeFromSuperview() 77 | } 78 | controller.removeFromParent() 79 | 80 | // Note: don't forget to nullify your own controller instance 81 | // in order to clear it out from memory 82 | } 83 | } 84 | 85 | -------------------------------------------------------------------------------- /Vendor/Controllers/StoryboardLoadable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryboardLoadable.swift 3 | // Radiant Tap Essentials 4 | // https://github.com/radianttap/swift-essentials 5 | // 6 | // Copyright © 2016 Radiant Tap 7 | // MIT License · http://choosealicense.com/licenses/mit/ 8 | // 9 | 10 | import UIKit 11 | 12 | @MainActor 13 | public protocol StoryboardLoadable { 14 | static var storyboardName: String { get } 15 | static var storyboardIdentifier: String { get } 16 | } 17 | 18 | 19 | extension StoryboardLoadable where Self: UIViewController { 20 | 21 | public static var storyboardName: String { 22 | return String(describing: self) 23 | } 24 | 25 | public static var storyboardIdentifier: String { 26 | return String(describing: self) 27 | } 28 | 29 | public static func instantiate(fromStoryboardNamed name: String? = nil) -> Self { 30 | let sb = name ?? self.storyboardName 31 | let storyboard = UIStoryboard(name: sb, bundle: nil) 32 | return instantiate(fromStoryboard: storyboard) 33 | } 34 | 35 | public static func instantiate(fromStoryboard storyboard: UIStoryboard) -> Self { 36 | let identifier = self.storyboardIdentifier 37 | guard let vc = storyboard.instantiateViewController(withIdentifier: identifier) as? Self else { 38 | fatalError("Failed to instantiate view controller with identifier=\(identifier) from storyboard \( storyboard )") 39 | } 40 | return vc 41 | 42 | } 43 | 44 | public static func initial(fromStoryboardNamed name: String? = nil) -> Self { 45 | let sb = name ?? self.storyboardName 46 | let storyboard = UIStoryboard(name: sb, bundle: nil) 47 | return initial(fromStoryboard: storyboard) 48 | } 49 | 50 | public static func initial(fromStoryboard storyboard: UIStoryboard) -> Self { 51 | guard let vc = storyboard.instantiateInitialViewController() as? Self else { 52 | fatalError("Failed to instantiate initial view controller from storyboard named \( storyboard )") 53 | } 54 | return vc 55 | } 56 | } 57 | 58 | 59 | extension UINavigationController: StoryboardLoadable {} 60 | extension UITabBarController: StoryboardLoadable {} 61 | extension UISplitViewController: StoryboardLoadable {} 62 | extension UIPageViewController: StoryboardLoadable {} 63 | -------------------------------------------------------------------------------- /Vendor/Notifications/Notifying.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notifying.swift 3 | // Radiant Tap Essentials 4 | // https://github.com/radianttap/swift-essentials 5 | // 6 | // Copyright © 2016 Radiant Tap 7 | // MIT License · http://choosealicense.com/licenses/mit/ 8 | // 9 | 10 | import Foundation 11 | 12 | public class NotificationToken { 13 | public let token: NSObjectProtocol 14 | public let center: NotificationCenter 15 | 16 | public init(token: NSObjectProtocol, center: NotificationCenter? = nil) { 17 | self.token = token 18 | self.center = center ?? NotificationCenter.default 19 | } 20 | 21 | deinit { 22 | center.removeObserver(token) 23 | } 24 | } 25 | 26 | public struct NotificationDescriptor: @unchecked Sendable { 27 | public let name: Notification.Name 28 | public var convert: (Notification) -> A? = {_ in return nil } 29 | } 30 | 31 | extension NotificationDescriptor { 32 | public init(name: Notification.Name) { 33 | self.name = name 34 | } 35 | } 36 | 37 | extension NotificationCenter { 38 | public func addObserver(for descriptor: NotificationDescriptor, 39 | queue: OperationQueue? = nil, 40 | using block: @escaping @Sendable (A) -> ()) -> NotificationToken { 41 | 42 | return NotificationToken( 43 | token: addObserver(forName: descriptor.name, object: nil, queue: queue, using: { 44 | note in 45 | guard let object = note.object as? A else { return } 46 | block(object) 47 | }), 48 | center: self 49 | ) 50 | } 51 | 52 | /// Use this to observe system Notifications. `A` must have proper `convert(Notification) -> A?` implementation 53 | public func addObserver(forConvertedDescriptor descriptor: NotificationDescriptor, 54 | queue: OperationQueue? = nil, 55 | using block: @escaping @Sendable (A) -> ()) -> NotificationToken { 56 | 57 | return NotificationToken( 58 | token: addObserver(forName: descriptor.name, object: nil, queue: queue, using: { 59 | note in 60 | guard let object = descriptor.convert(note) else { return } 61 | block(object) 62 | }), 63 | center: self 64 | ) 65 | } 66 | 67 | public func post(_ descriptor: NotificationDescriptor, value: A) { 68 | post(name: descriptor.name, object: value) 69 | } 70 | } 71 | 72 | -------------------------------------------------------------------------------- /Vendor/Notifications/UIContentSizeCategory-Notifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIContentSizeCategory-Notifications.swift 3 | // Radiant Tap Essentials 4 | // 5 | // Copyright © 2018 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | #if canImport(UIKit) 10 | import UIKit 11 | 12 | @available(iOS 10.0, *) @MainActor 13 | public struct ContentSizeCategoryNotification: Sendable { 14 | public var category: UIContentSizeCategory = .unspecified 15 | 16 | init?(notification: Notification) { 17 | guard let userInfo = notification.userInfo as? [String: Any] else { return nil } 18 | 19 | if 20 | let value = userInfo[UIContentSizeCategory.newValueUserInfoKey] as? String 21 | { 22 | self.category = UIContentSizeCategory(rawValue: value) 23 | } 24 | } 25 | } 26 | 27 | @available(iOS 10.0, *) 28 | public extension ContentSizeCategoryNotification { 29 | static let didChange = NotificationDescriptor( 30 | name: UIContentSizeCategory.didChangeNotification, 31 | convert: ContentSizeCategoryNotification.init 32 | ) 33 | } 34 | #endif 35 | -------------------------------------------------------------------------------- /Vendor/Notifications/UIKeyboard-Notifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIKeyboard-Notifications.swift 3 | // Radiant Tap Essentials 4 | // 5 | // Copyright © 2017 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | #if canImport(UIKit) 10 | import UIKit 11 | 12 | // @available(iOS 3.2, *) 13 | // public let UIKeyboardFrameBeginUserInfoKey: String 14 | // NSValue of CGRect 15 | 16 | // @available(iOS 3.2, *) 17 | // public let UIKeyboardFrameEndUserInfoKey: String 18 | // NSValue of CGRect 19 | 20 | // @available(iOS 3.0, *) 21 | // public let UIKeyboardAnimationDurationUserInfoKey: String 22 | // NSNumber of double 23 | 24 | // @available(iOS 3.0, *) 25 | // public let UIKeyboardAnimationCurveUserInfoKey: String 26 | // NSNumber of NSUInteger (UIViewAnimationCurve) 27 | 28 | // @available(iOS 9.0, *) 29 | // public let UIKeyboardIsLocalUserInfoKey: String 30 | // NSNumber of BOOL 31 | 32 | @MainActor 33 | public struct KeyboardNotification: Sendable { 34 | public var beginFrame: CGRect = .zero 35 | public var endFrame: CGRect = .zero 36 | public var animationCurve: UIView.AnimationCurve = .linear 37 | public var animationDuration: TimeInterval = 0.3 38 | public var isLocalForCurrentApp: Bool = false 39 | 40 | init?(notification: Notification) { 41 | guard let userInfo = notification.userInfo as? [String: Any] else { return nil } 42 | 43 | if let value = userInfo[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue { 44 | beginFrame = value.cgRectValue 45 | } 46 | if let value = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { 47 | endFrame = value.cgRectValue 48 | } 49 | if let value = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int, let curve = UIView.AnimationCurve(rawValue: value) { 50 | animationCurve = curve 51 | } 52 | if let value = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval { 53 | animationDuration = value 54 | } 55 | if let value = userInfo[UIResponder.keyboardIsLocalUserInfoKey] as? Bool { 56 | isLocalForCurrentApp = value 57 | } 58 | } 59 | } 60 | 61 | public extension KeyboardNotification { 62 | static let willShow = NotificationDescriptor(name: UIResponder.keyboardWillShowNotification, convert: KeyboardNotification.init) 63 | static let didShow = NotificationDescriptor(name: UIResponder.keyboardDidShowNotification, convert: KeyboardNotification.init) 64 | 65 | static let willHide = NotificationDescriptor(name: UIResponder.keyboardWillHideNotification, convert: KeyboardNotification.init) 66 | static let didHide = NotificationDescriptor(name: UIResponder.keyboardDidHideNotification, convert: KeyboardNotification.init) 67 | } 68 | #endif 69 | -------------------------------------------------------------------------------- /Vendor/UIKit/ActionClosurable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionClosurable.swift 3 | // ActionClosurable 4 | // 5 | // Created by Yoshitaka Seki on 2016/04/11. 6 | // Copyright © 2016年 Yoshitaka Seki. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @MainActor 12 | private class Actor { 13 | @objc func act(sender: AnyObject) { closure(sender as! T) } 14 | fileprivate let closure: (T) -> Void 15 | init(acts closure: @escaping (T) -> Void) { 16 | self.closure = closure 17 | } 18 | } 19 | 20 | @MainActor 21 | private class GreenRoom { 22 | fileprivate var actors: [Any] = [] 23 | } 24 | 25 | @MainActor 26 | private var GreenRoomKey: UInt32 = 893 27 | 28 | @MainActor 29 | private func register(_ actor: Actor, to object: AnyObject) { 30 | let room = objc_getAssociatedObject(object, &GreenRoomKey) as? GreenRoom ?? { 31 | let room = GreenRoom() 32 | objc_setAssociatedObject(object, &GreenRoomKey, room, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 33 | return room 34 | }() 35 | room.actors.append(actor) 36 | } 37 | 38 | @MainActor 39 | public protocol ActionClosurable {} 40 | 41 | public extension ActionClosurable where Self: AnyObject { 42 | func convert(closure: @escaping (Self) -> Void, toConfiguration configure: (AnyObject, Selector) -> Void) { 43 | let actor = Actor(acts: closure) 44 | configure(actor, #selector(Actor.act(sender:))) 45 | register(actor, to: self) 46 | } 47 | static func convert(closure: @escaping (Self) -> Void, toConfiguration configure: (AnyObject, Selector) -> Self) -> Self { 48 | let actor = Actor(acts: closure) 49 | let instance = configure(actor, #selector(Actor.act(sender:))) 50 | register(actor, to: instance) 51 | return instance 52 | } 53 | } 54 | 55 | extension UIBarButtonItem: ActionClosurable {} 56 | extension UIControl: ActionClosurable {} 57 | extension UIGestureRecognizer: ActionClosurable {} 58 | 59 | // 60 | // Extensions.swift 61 | // ActionClosurable 62 | // 63 | // Created by Yoshitaka Seki on 2016/04/11. 64 | // Copyright © 2016年 Yoshitaka Seki. All rights reserved. 65 | // 66 | 67 | 68 | extension ActionClosurable where Self: UIControl { 69 | public func on(_ controlEvents: UIControl.Event, closure: @escaping (Self) -> Void) { 70 | convert(closure: closure, toConfiguration: { 71 | self.addTarget($0, action: $1, for: controlEvents) 72 | }) 73 | } 74 | } 75 | 76 | extension ActionClosurable where Self: UIButton { 77 | public func onTap(_ closure: @escaping (Self) -> Void) { 78 | convert(closure: closure, toConfiguration: { 79 | self.addTarget($0, action: $1, for: .touchUpInside) 80 | }) 81 | } 82 | 83 | // custom back button with 84 | public init(backButtonWithTitle title: String, closure: @escaping (Self) -> Void) { 85 | self.init(type: .custom) 86 | 87 | let img = UIImage(imageLiteralResourceName: "icon-previous") 88 | self.setImage(img, for: .normal) 89 | self.setTitle(title, for: .normal) 90 | self.onTap(closure) 91 | } 92 | } 93 | 94 | 95 | extension ActionClosurable where Self: UIGestureRecognizer { 96 | public func onGesture(_ closure: @escaping (Self) -> Void) { 97 | convert(closure: closure, toConfiguration: { 98 | self.addTarget($0, action: $1) 99 | }) 100 | } 101 | public init(closure: @escaping (Self) -> Void) { 102 | self.init() 103 | onGesture(closure) 104 | } 105 | } 106 | 107 | extension ActionClosurable where Self: UIBarButtonItem { 108 | public init(barButtonSystemItem: UIBarButtonItem.SystemItem, closure: @escaping (Self) -> Void) { 109 | self.init(barButtonSystemItem: barButtonSystemItem, target: nil, action: nil) 110 | self.onTap(closure) 111 | } 112 | public init(title: String, style: UIBarButtonItem.Style = .plain, closure: @escaping (Self) -> Void) { 113 | self.init(title: title, style: style, target: nil, action: nil) 114 | self.onTap(closure) 115 | } 116 | public init(image: UIImage?, style: UIBarButtonItem.Style = .plain, closure: @escaping (Self) -> Void) { 117 | self.init(image: image, style: style, target: nil, action: nil) 118 | self.onTap(closure) 119 | } 120 | public func onTap(_ closure: @escaping (Self) -> Void) { 121 | convert(closure: closure, toConfiguration: { 122 | self.target = $0 123 | self.action = $1 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Vendor/UIKit/UIButton-Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIButton-Extensions.swift 3 | // Radiant Tap Essentials 4 | // 5 | // Copyright © 2018 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIButton { 12 | /// Allows automatic response on contentSize change (dynamic type) 13 | @IBInspectable 14 | var adjustsFontForContentSizeCategory: Bool { 15 | set { 16 | self.titleLabel?.adjustsFontForContentSizeCategory = newValue 17 | } 18 | get { 19 | return self.titleLabel?.adjustsFontForContentSizeCategory ?? false 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Vendor/UIKit/UIColor-Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor-Extensions.swift 3 | // Radiant Tap Essentials 4 | // 5 | // Copyright © 2016 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | // Credits: 9 | // https://cocoacasts.com/from-hex-to-uicolor-and-back-in-swift/ 10 | // https://medium.com/ios-os-x-development/ios-extend-uicolor-with-custom-colors-93366ae148e6 11 | // 12 | #if os(iOS) 13 | import UIKit 14 | #endif 15 | 16 | #if os(watchOS) 17 | import WatchKit 18 | #endif 19 | 20 | extension UIColor { 21 | convenience init?(hex: String) { 22 | var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) 23 | hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") 24 | 25 | var rgb: UInt64 = 0 26 | if !Scanner(string: hexSanitized).scanHexInt64(&rgb) { return nil } 27 | 28 | var r: CGFloat = 0.0 29 | var g: CGFloat = 0.0 30 | var b: CGFloat = 0.0 31 | var a: CGFloat = 1.0 32 | 33 | let length = hexSanitized.count 34 | if length == 6 { //RGB 35 | r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0 36 | g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0 37 | b = CGFloat(rgb & 0x0000FF) / 255.0 38 | 39 | } else if length == 8 { // RGBa 40 | r = CGFloat((rgb & 0xFF000000) >> 24) / 255.0 41 | g = CGFloat((rgb & 0x00FF0000) >> 16) / 255.0 42 | b = CGFloat((rgb & 0x0000FF00) >> 8) / 255.0 43 | a = CGFloat(rgb & 0x000000FF) / 255.0 44 | 45 | } else { 46 | return nil 47 | } 48 | 49 | self.init(red: r, green: g, blue: b, alpha: a) 50 | } 51 | 52 | func toHex(alpha: Bool = false) -> String? { 53 | guard let components = cgColor.components, components.count >= 3 else { 54 | return nil 55 | } 56 | let r = Float(components[0]) 57 | let g = Float(components[1]) 58 | let b = Float(components[2]) 59 | var a = Float(1.0) 60 | 61 | if components.count >= 4 { 62 | a = Float(components[3]) 63 | } 64 | 65 | /* 66 | The string format specifier may need an explanation. 67 | You can break it down into several components. 68 | * % defines the format specifier 69 | * 02 defines the length of the string 70 | * l casts the value to an unsigned long 71 | * X prints the value in hexadecimal (0–9 and A-F) 72 | */ 73 | if alpha { 74 | return String(format: "%02lX%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255), lroundf(a * 255)) 75 | } else { 76 | return String(format: "%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255)) 77 | } 78 | } 79 | 80 | var toHex: String? { 81 | return toHex() 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Vendor/UIKit/UIEdgeInsets-Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor-Extensions.swift 3 | // Radiant Tap Essentials 4 | // 5 | // Copyright © 2018 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIEdgeInsets { 12 | /// Subtracts components in the two arguments and returns the resulting `UIEdgeInsets`. Makes sure that result is never below 0. 13 | public static func -(lhs: UIEdgeInsets, rhs:UIEdgeInsets) -> UIEdgeInsets { 14 | var insets = UIEdgeInsets.zero 15 | insets.top = max(0, lhs.top - rhs.top) 16 | insets.right = max(0, lhs.right - rhs.right) 17 | insets.bottom = max(0, lhs.bottom - rhs.bottom) 18 | insets.left = max(0, lhs.left - rhs.left) 19 | return insets 20 | } 21 | 22 | /// Adds each component in the two arguments and returns the resulting `UIEdgeInsets`. 23 | public static func +(lhs: UIEdgeInsets, rhs:UIEdgeInsets) -> UIEdgeInsets { 24 | var insets = UIEdgeInsets.zero 25 | insets.top = lhs.top + rhs.top 26 | insets.right = lhs.right + rhs.right 27 | insets.bottom = lhs.bottom + rhs.bottom 28 | insets.left = lhs.left + rhs.left 29 | return insets 30 | } 31 | 32 | /// Compares each component and uses the larger value in the resulting `UIEdgeInsets`. 33 | public func union(_ rhs: UIEdgeInsets) -> UIEdgeInsets { 34 | var insets = UIEdgeInsets.zero 35 | insets.top = max(top, insets.top) 36 | insets.right = max(right, insets.right) 37 | insets.bottom = max(bottom, insets.bottom) 38 | insets.left = max(left, insets.left) 39 | return insets 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Vendor/UIKit/UIStackView-Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIStackView { 4 | func removeAllSubviews() { 5 | for v in arrangedSubviews { 6 | removeArrangedSubview(v) 7 | v.removeFromSuperview() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Vendor/Views/DequeableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DequeableView.swift 3 | // Radiant Tap Essentials 4 | // https://github.com/radianttap/swift-essentials 5 | // 6 | // Copyright © 2016 Radiant Tap 7 | // MIT License · http://choosealicense.com/licenses/mit/ 8 | // 9 | 10 | import UIKit 11 | 12 | 13 | extension UICollectionView { 14 | 15 | // register for the Class-based cell 16 | public func register(_: T.Type, withReuseIdentifier reuseIdentifier: String = T.reuseIdentifier) 17 | where T: ReusableView 18 | { 19 | register(T.self, forCellWithReuseIdentifier: reuseIdentifier) 20 | } 21 | 22 | // register for the Nib-based cell 23 | public func register(_: T.Type, withReuseIdentifier reuseIdentifier: String = T.reuseIdentifier) 24 | where T:NibReusableView 25 | { 26 | register(T.nib, forCellWithReuseIdentifier: reuseIdentifier) 27 | } 28 | 29 | public func dequeueReusableCell(withReuseIdentifier reuseIdentifier: String = T.reuseIdentifier, forIndexPath indexPath: IndexPath) -> T 30 | where T:ReusableView 31 | { 32 | // this deque and cast can fail if you forget to register the proper cell 33 | guard let cell = dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as? T else { 34 | // thus crash instantly and nudge the developer 35 | fatalError("Dequeing a cell with identifier: \( reuseIdentifier ) failed.\nDid you maybe forget to register it in viewDidLoad?") 36 | } 37 | return cell 38 | } 39 | 40 | // register for the Class-based supplementary view 41 | public func register(_: T.Type, kind: String) 42 | where T:ReusableView 43 | { 44 | register(T.self, forSupplementaryViewOfKind: kind, withReuseIdentifier: T.reuseIdentifier) 45 | } 46 | 47 | // register for the Nib-based supplementary view 48 | public func register(_: T.Type, kind: String) 49 | where T:NibReusableView 50 | { 51 | register(T.nib, forSupplementaryViewOfKind: kind, withReuseIdentifier: T.reuseIdentifier) 52 | } 53 | 54 | public func dequeueReusableView(kind: String, atIndexPath indexPath: IndexPath) -> T 55 | where T:ReusableView 56 | { 57 | // this deque and cast can fail if you forget to register the proper cell 58 | guard let view = dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: T.reuseIdentifier, for: indexPath) as? T else { 59 | // thus crash instantly and nudge the developer 60 | fatalError("Dequeing supplementary view of kind: \( kind ) with identifier: \( T.reuseIdentifier ) failed.\nDid you maybe forget to register it in viewDidLoad?") 61 | } 62 | return view 63 | } 64 | } 65 | 66 | 67 | extension UITableView { 68 | 69 | // register for the Class-based cell 70 | public func register(_: T.Type, withReuseIdentifier reuseIdentifier: String = T.reuseIdentifier) 71 | where T: ReusableView 72 | { 73 | register(T.self, forCellReuseIdentifier: reuseIdentifier) 74 | } 75 | 76 | // register for the Nib-based cell 77 | public func register(_: T.Type, withReuseIdentifier reuseIdentifier: String = T.reuseIdentifier) 78 | where T:NibReusableView 79 | { 80 | register(T.nib, forCellReuseIdentifier: reuseIdentifier) 81 | } 82 | 83 | public func dequeueReusableCell(withReuseIdentifier reuseIdentifier: String = T.reuseIdentifier, forIndexPath indexPath: IndexPath) -> T 84 | where T:ReusableView 85 | { 86 | guard let cell = dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) as? T else { 87 | fatalError("Dequeing a cell with identifier: \(reuseIdentifier) failed.\nDid you maybe forget to register it in viewDidLoad?") 88 | } 89 | return cell 90 | } 91 | 92 | // register for the Class-based header/footer view 93 | public func register(_: T.Type) 94 | where T:ReusableView 95 | { 96 | register(T.self, forHeaderFooterViewReuseIdentifier: T.reuseIdentifier) 97 | } 98 | 99 | // register for the Nib-based header/footer view 100 | public func register(_: T.Type) 101 | where T:NibReusableView 102 | { 103 | register(T.nib, forHeaderFooterViewReuseIdentifier: T.reuseIdentifier) 104 | } 105 | 106 | public func dequeueReusableView() -> T? 107 | where T:ReusableView 108 | { 109 | let v = dequeueReusableHeaderFooterView(withIdentifier: T.reuseIdentifier) as? T 110 | return v 111 | } 112 | } 113 | 114 | -------------------------------------------------------------------------------- /Vendor/Views/NibLoadable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NibLoadable.swift 3 | // Radiant Tap Essentials 4 | // https://github.com/radianttap/swift-essentials 5 | // 6 | // Copyright © 2016 Radiant Tap 7 | // MIT License · http://choosealicense.com/licenses/mit/ 8 | // 9 | 10 | import UIKit 11 | 12 | /// Adopt this protocol on all subclasses of UITableViewCell and UICollectionViewCell 13 | /// that use their own .xib file 14 | @MainActor 15 | public protocol NibLoadableView { 16 | /// By default, it returns the subclass name 17 | static var nibName: String { get } 18 | 19 | /// Instantiates UINib using `nibName` as the name, from the main bundle 20 | static var nib: UINib { get } 21 | } 22 | 23 | extension NibLoadableView where Self: UIView { 24 | public static var nibName: String { 25 | return String(describing: self) 26 | } 27 | 28 | public static var nib: UINib { 29 | return UINib(nibName: self.nibName, bundle: nil) 30 | } 31 | } 32 | 33 | @MainActor 34 | public protocol NibReusableView : ReusableView, NibLoadableView {} 35 | 36 | 37 | 38 | /// Adopt this in cases where you need to create an ad-hoc instance of the given view 39 | /// Can be adopted only by classes marked as `final`, due to `Self` constraint 40 | @MainActor 41 | public protocol NibLoadableFinalView: NibLoadableView { 42 | /// Creates an instance of the cell from the `nibName`.xib file 43 | static var nibInstance : Self { get } 44 | } 45 | 46 | extension NibLoadableFinalView { 47 | public static var nibInstance : Self { 48 | guard let nibObject = self.nib.instantiate(withOwner: nil, options: nil).last as? Self else { 49 | fatalError("Failed to create an instance of \(self) from \(self.nibName) nib.") 50 | } 51 | return nibObject 52 | } 53 | } 54 | 55 | -------------------------------------------------------------------------------- /Vendor/Views/ReusableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReusableView.swift 3 | // Radiant Tap Essentials 4 | // https://github.com/radianttap/swift-essentials 5 | // 6 | // Copyright © 2016 Radiant Tap 7 | // MIT License · http://choosealicense.com/licenses/mit/ 8 | // 9 | 10 | import UIKit 11 | 12 | /// Protocol to allow any UIView to become reusable view 13 | @MainActor 14 | public protocol ReusableView { 15 | /// By default, it returns the subclass name 16 | static var reuseIdentifier: String { get } 17 | } 18 | 19 | extension ReusableView where Self: UIView { 20 | public static var reuseIdentifier: String { 21 | return String(describing: self) 22 | } 23 | } 24 | 25 | 26 | -------------------------------------------------------------------------------- /demo-app/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // 4 | 5 | import UIKit 6 | 7 | @main 8 | class AppDelegate: UIResponder, UIApplicationDelegate { 9 | 10 | var window: UIWindow? 11 | 12 | func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { 13 | window = UIWindow(frame: UIScreen.main.bounds) 14 | 15 | let vc = prepareLogin() 16 | 17 | // UIKit setup 18 | let nc = UINavigationController(rootViewController: vc) 19 | window?.rootViewController = nc 20 | 21 | return true 22 | } 23 | 24 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 25 | window?.makeKeyAndVisible() 26 | return true 27 | } 28 | } 29 | 30 | private extension AppDelegate { 31 | func prepareLogin() -> LoginController { 32 | let vc = LoginController() 33 | 34 | // Model (data source) 35 | let user = User() 36 | let ds = LoginDataSource(user) 37 | vc.dataSource = ds 38 | 39 | return vc 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/customer_service.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "customer_service@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/customer_service.imageset/customer_service@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radianttap/Fields/b47a9a9ca67907c2f0b02fc29a1acb760f66fbe4/demo-app/Assets.xcassets/customer_service.imageset/customer_service@2x.png -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/icon-dress.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Fill 267.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/icon-dress.imageset/Fill 267.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radianttap/Fields/b47a9a9ca67907c2f0b02fc29a1acb760f66fbe4/demo-app/Assets.xcassets/icon-dress.imageset/Fill 267.pdf -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/icon-handbag.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Fill 274.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/icon-handbag.imageset/Fill 274.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radianttap/Fields/b47a9a9ca67907c2f0b02fc29a1acb760f66fbe4/demo-app/Assets.xcassets/icon-handbag.imageset/Fill 274.pdf -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/icon-makeup.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Fill 266.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/icon-makeup.imageset/Fill 266.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radianttap/Fields/b47a9a9ca67907c2f0b02fc29a1acb760f66fbe4/demo-app/Assets.xcassets/icon-makeup.imageset/Fill 266.pdf -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/icon-tshirt.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Fill 264.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/icon-tshirt.imageset/Fill 264.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radianttap/Fields/b47a9a9ca67907c2f0b02fc29a1acb760f66fbe4/demo-app/Assets.xcassets/icon-tshirt.imageset/Fill 264.pdf -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/icon-underwear.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Fill 265.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/icon-underwear.imageset/Fill 265.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radianttap/Fields/b47a9a9ca67907c2f0b02fc29a1acb760f66fbe4/demo-app/Assets.xcassets/icon-underwear.imageset/Fill 265.pdf -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/icon-watch.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Fill 263.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } -------------------------------------------------------------------------------- /demo-app/Assets.xcassets/icon-watch.imageset/Fill 263.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radianttap/Fields/b47a9a9ca67907c2f0b02fc29a1acb760f66fbe4/demo-app/Assets.xcassets/icon-watch.imageset/Fill 263.pdf -------------------------------------------------------------------------------- /demo-app/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /demo-app/CustomCells/ForgotPassCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class ForgotPassCell: FormFieldCell, NibReusableView { 4 | } 5 | -------------------------------------------------------------------------------- /demo-app/CustomCells/ForgotPassCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /demo-app/CustomCells/InventoryCategoryCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class InventoryCategoryCell: FormFieldCell, NibReusableView { 4 | @IBOutlet private var iconView: UIImageView! 5 | 6 | var isChosen: Bool = false { 7 | didSet { 8 | applyTheme() 9 | } 10 | } 11 | 12 | private var ic: InventoryCategory? 13 | private var valueSelected: (InventoryCategory, FormFieldCell) -> Void = {_, _ in} 14 | } 15 | 16 | extension InventoryCategoryCell { 17 | override func postAwakeFromNib() { 18 | super.postAwakeFromNib() 19 | cleanup() 20 | } 21 | 22 | override func prepareForReuse() { 23 | super.prepareForReuse() 24 | cleanup() 25 | } 26 | 27 | func populate(with model: SingleValueModel) { 28 | switch model { 29 | case let model as SingleValueModel: 30 | iconView.image = model.value.icon 31 | ic = model.value 32 | valueSelected = model.valueSelected 33 | 34 | default: 35 | break 36 | } 37 | 38 | isChosen = model.isChosen 39 | } 40 | } 41 | 42 | private extension InventoryCategoryCell { 43 | func cleanup() { 44 | iconView.image = nil 45 | isChosen = false 46 | } 47 | 48 | func applyTheme() { 49 | if isChosen { 50 | iconView.tintColor = .blue 51 | } else { 52 | iconView.tintColor = .darkText 53 | } 54 | } 55 | 56 | @IBAction func tapped(_ sender: UIControl) { 57 | guard let ic = ic else { return } 58 | valueSelected(ic, self) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /demo-app/CustomCells/InventoryCategoryCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /demo-app/CustomCells/OptionCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class OptionCell: UICollectionViewCell, NibReusableView { 4 | @IBOutlet private var label: UILabel! 5 | 6 | func populate(_ string: String) { 7 | label.text = string 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /demo-app/CustomCells/OptionCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /demo-app/CustomCells/SectionFooterView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SectionFooterView.swift 3 | // 4 | 5 | import UIKit 6 | 7 | final class SectionFooterView: FormSupplementaryView, NibReusableView { 8 | static let kind: String = UICollectionView.elementKindSectionFooter 9 | 10 | // UI 11 | @IBOutlet private var textLabel: UILabel! 12 | 13 | private var text: String? 14 | } 15 | 16 | 17 | extension SectionFooterView { 18 | override func postAwakeFromNib() { 19 | super.postAwakeFromNib() 20 | cleanup() 21 | } 22 | 23 | override func prepareForReuse() { 24 | super.prepareForReuse() 25 | cleanup() 26 | } 27 | 28 | override func updateConstraints() { 29 | textLabel.preferredMaxLayoutWidth = textLabel.bounds.width 30 | super.updateConstraints() 31 | } 32 | 33 | func populate(with text: String) { 34 | self.text = text 35 | render() 36 | } 37 | } 38 | 39 | private extension SectionFooterView { 40 | func cleanup() { 41 | textLabel.text = nil 42 | } 43 | 44 | func render() { 45 | textLabel.text = text 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /demo-app/CustomCells/SectionFooterView.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /demo-app/CustomCells/SectionHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SectionHeaderView.swift 3 | // 4 | 5 | import UIKit 6 | 7 | final class SectionHeaderView: FormSupplementaryView, NibReusableView { 8 | static let kind: String = UICollectionView.elementKindSectionHeader 9 | 10 | // UI 11 | @IBOutlet private var titleLabel: UILabel! 12 | 13 | private var title: String? 14 | } 15 | 16 | 17 | extension SectionHeaderView { 18 | override func postAwakeFromNib() { 19 | super.postAwakeFromNib() 20 | cleanup() 21 | } 22 | 23 | override func prepareForReuse() { 24 | super.prepareForReuse() 25 | cleanup() 26 | } 27 | 28 | override func updateConstraints() { 29 | titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width 30 | super.updateConstraints() 31 | } 32 | 33 | func populate(with title: String) { 34 | self.title = title 35 | render() 36 | } 37 | } 38 | 39 | private extension SectionHeaderView { 40 | func cleanup() { 41 | titleLabel.text = nil 42 | } 43 | 44 | func render() { 45 | titleLabel.text = title?.uppercased() 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /demo-app/CustomCells/SectionHeaderView.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 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 | -------------------------------------------------------------------------------- /demo-app/DataModel/Address.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Address.swift 3 | // Fields-demo 4 | // 5 | // Created by Aleksandar Vacić on 6/27/19. 6 | // Copyright © 2019 Radiant Tap. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Address: Equatable { 12 | var street: String? 13 | var city: String? 14 | var postCode: String? 15 | var isoCountryCode: String? 16 | 17 | init() { 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /demo-app/DataModel/InventoryCategory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InventoryCategory.swift 3 | // Fields-demo 4 | // 5 | // Created by Aleksandar Vacić on 8/11/19. 6 | // Copyright © 2019 Radiant Tap. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct InventoryCategory: Hashable, Identifiable { 12 | enum ID: String, CaseIterable { 13 | case watch 14 | case dress 15 | case handbag 16 | case makeup 17 | case underwear 18 | } 19 | 20 | let id: ID 21 | 22 | init(id: ID) { 23 | self.id = id 24 | } 25 | } 26 | 27 | extension InventoryCategory { 28 | static let allCategories = InventoryCategory.ID.allCases.map { InventoryCategory(id: $0) } 29 | } 30 | -------------------------------------------------------------------------------- /demo-app/DataModel/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // 4 | 5 | import Foundation 6 | 7 | struct User: Equatable { 8 | var username: String? 9 | var password: String? 10 | 11 | var title: PersonTitle? 12 | var firstName: String? 13 | var lastName: String? 14 | 15 | var postalAddress: Address? 16 | var billingAddress: Address? 17 | 18 | var dateOfBirth: Date? 19 | 20 | init() { 21 | } 22 | } 23 | 24 | enum PersonTitle: String, CaseIterable { 25 | case Mr 26 | case Mrs 27 | case Miss 28 | } 29 | -------------------------------------------------------------------------------- /demo-app/ForgotPasswordController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class ForgotPasswordController: FieldsController, StoryboardLoadable { 4 | // UI 5 | 6 | @IBOutlet private var scrollView: UIScrollView! 7 | @IBOutlet private var emailField: TextFieldCell! 8 | 9 | var dataSource: ForgotPasswordDataSource? { 10 | didSet { 11 | if !isViewLoaded { return } 12 | 13 | prepare(dataSource) 14 | render(dataSource) 15 | } 16 | } 17 | 18 | override func renderContentUpdates() { 19 | if !isViewLoaded { return } 20 | 21 | render(dataSource) 22 | } 23 | } 24 | 25 | extension ForgotPasswordController { 26 | override func viewDidLoad() { 27 | super.viewDidLoad() 28 | 29 | setupUI() 30 | 31 | prepare(dataSource) 32 | render(dataSource) 33 | } 34 | } 35 | 36 | private extension ForgotPasswordController { 37 | func setupUI() { 38 | title = "Password reset" 39 | 40 | // hack to use UICVCell subclass inside storyboard, outside of UICV 41 | [emailField].forEach { 42 | $0?.contentView.removeFromSuperview() 43 | $0?.translatesAutoresizingMaskIntoConstraints = false 44 | } 45 | } 46 | 47 | func prepare(_ dataSource: ForgotPasswordDataSource?) { 48 | dataSource?.controller = self 49 | } 50 | 51 | func render(_ dataSource: ForgotPasswordDataSource?) { 52 | guard let dataSource = dataSource else { return } 53 | 54 | emailField.populate(with: dataSource.emailModel) 55 | } 56 | 57 | // MARK:- Actions 58 | 59 | @IBAction func send(_ sender: UIButton) { 60 | 61 | } 62 | 63 | @IBAction func openCredits(_ sender: UIButton) { 64 | guard let url = URL(string: "https://www.iconfinder.com/rizal999") else { return } 65 | UIApplication.shared.open(url) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /demo-app/ForgotPasswordDataSource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @MainActor 4 | final class ForgotPasswordDataSource: NSObject { 5 | // Dependencies 6 | 7 | weak var controller: ForgotPasswordController? { 8 | didSet { prepareView() } 9 | } 10 | 11 | init(user: User?) { 12 | self.email = user?.username 13 | } 14 | 15 | 16 | // Data model 17 | 18 | private var email: String? 19 | private(set) var error: Error? 20 | 21 | private(set) lazy var emailModel: TextFieldModel = makeEmailFormModel() 22 | 23 | // MARK:- Form Fields 24 | 25 | /// Possible form fields 26 | enum FieldId: String { 27 | case email 28 | } 29 | 30 | /// Dictionary of errors to show, per field. 31 | private var fieldErrors: [FieldId: String] = [:] 32 | } 33 | 34 | private extension ForgotPasswordDataSource { 35 | func prepareView() {} 36 | 37 | func renderContentUpdates() { 38 | controller?.renderContentUpdates() 39 | } 40 | 41 | func makeEmailFormModel() -> TextFieldModel { 42 | let model = TextFieldModel( 43 | id: FieldId.email.rawValue, 44 | title: NSLocalizedString("Email (username)", comment: ""), 45 | value: email 46 | ) 47 | model.customSetup = { textField in 48 | textField.textContentType = .emailAddress 49 | textField.keyboardType = .emailAddress 50 | } 51 | model.valueChanged = { 52 | [weak self] string, _ in 53 | 54 | self?.email = string 55 | model.value = string 56 | } 57 | return model 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /demo-app/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIRequiredDeviceCapabilities 26 | 27 | armv7 28 | 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /demo-app/LoginController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class LoginController: FieldsCollectionController { 4 | 5 | // MARK:- View lifecycle 6 | 7 | override func viewDidLoad() { 8 | super.viewDidLoad() 9 | 10 | setupUI() 11 | applyTheme() 12 | } 13 | } 14 | 15 | private extension LoginController { 16 | // MARK:- Internal 17 | 18 | func applyTheme() { 19 | view.backgroundColor = .systemBackground 20 | } 21 | 22 | func setupUI() { 23 | title = "Login" 24 | 25 | navigationItem.leftBarButtonItem = { 26 | let bbi = UIBarButtonItem(title: NSLocalizedString("Sign up", comment: ""), style: .plain, target: self, action: #selector(openAccount)) 27 | return bbi 28 | }() 29 | 30 | collectionView.delegate = self 31 | } 32 | 33 | func prepare(_ dataSource: LoginDataSource?) { 34 | dataSource?.controller = self 35 | } 36 | 37 | func render(_ dataSource: LoginDataSource?) { 38 | collectionView.reloadData() 39 | } 40 | 41 | // MARK:- Actions 42 | 43 | @objc func openAccount(_ sender: UIBarButtonItem) { 44 | guard 45 | let ds = dataSource as? LoginDataSource, 46 | let user = ds.user 47 | else { return } 48 | 49 | let vc = RegisterController() 50 | vc.dataSource = RegisterDataSource(user) 51 | 52 | show(vc, sender: self) 53 | } 54 | } 55 | 56 | extension LoginController: UICollectionViewDelegate { 57 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 58 | guard 59 | let ds = dataSource as? LoginDataSource 60 | else { return } 61 | 62 | let fieldId = ds.field(at: indexPath) 63 | 64 | switch fieldId { 65 | case .forgotpassword: 66 | let vc = ForgotPasswordController.instantiate() 67 | vc.dataSource = ForgotPasswordDataSource(user: ds.user) 68 | show(vc, sender: self) 69 | 70 | default: 71 | break 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /demo-app/LoginDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginDataSource.swift 3 | // 4 | 5 | import UIKit 6 | 7 | final class LoginDataSource: FieldsDataSource { 8 | // Model 9 | var user: User? 10 | 11 | /// View-model for the form fields 12 | private var fieldIds: [FieldSection.ID] = [] 13 | private var fieldsMap: [FieldModel.ID: FieldModel] = [:] 14 | 15 | // Init 16 | 17 | init(_ user: User) { 18 | self.user = user 19 | super.init() 20 | 21 | areSeparatorsEnabled = false 22 | prepareFields() 23 | } 24 | 25 | enum FieldId: String, CaseIterable { 26 | case info 27 | case username 28 | case password 29 | case forgotpassword 30 | case submit 31 | } 32 | 33 | // MARK: FieldDataSource 34 | 35 | override func registerReusableElements(for cv: UICollectionView) { 36 | super.registerReusableElements(for: cv) 37 | 38 | [FieldId.username, .password].forEach { 39 | [unowned self] fieldId in 40 | 41 | self.cellRegistrations[fieldId.rawValue] = UICollectionView.CellRegistration(cellNib: TextFieldCell.nib) { 42 | [weak self] cell, indexPath, itemIdentifier in 43 | guard 44 | let cell = cell as? TextFieldCell, 45 | let model = self?.fieldsMap[itemIdentifier] as? TextFieldModel 46 | else { return } 47 | 48 | cell.populate(with: model) 49 | } 50 | } 51 | 52 | [FieldId.submit].forEach { 53 | [unowned self] fieldId in 54 | 55 | self.cellRegistrations[fieldId.rawValue] = UICollectionView.CellRegistration(cellNib: FormButtonCell.nib) { 56 | [weak self] cell, indexPath, itemIdentifier in 57 | guard 58 | let cell = cell as? FormButtonCell, 59 | let model = self?.fieldsMap[itemIdentifier] as? FormButtonModel 60 | else { return } 61 | 62 | cell.populate(with: model) 63 | } 64 | } 65 | 66 | [FieldId.info].forEach { 67 | [unowned self] fieldId in 68 | 69 | self.cellRegistrations[fieldId.rawValue] = UICollectionView.CellRegistration(cellNib: FormTextCell.nib) { 70 | [weak self] cell, indexPath, itemIdentifier in 71 | guard 72 | let cell = cell as? FormTextCell, 73 | let model = self?.fieldsMap[itemIdentifier] as? FormTextModel 74 | else { return } 75 | 76 | cell.populate(with: model) 77 | } 78 | } 79 | 80 | [FieldId.forgotpassword].forEach { 81 | [unowned self] fieldId in 82 | 83 | self.cellRegistrations[fieldId.rawValue] = UICollectionView.CellRegistration(cellNib: ForgotPassCell.nib) { 84 | _, _, _ in 85 | } 86 | } 87 | } 88 | 89 | override func populateSnapshot(flowIdentifier fid: String) -> FieldsDataSource.Snapshot { 90 | var snapshot = Snapshot() 91 | 92 | let sectionId = "form" 93 | snapshot.appendSections([sectionId]) 94 | snapshot.appendItems(fieldIds, toSection: sectionId) 95 | snapshot.reconfigureItems(fieldIds) 96 | 97 | return snapshot 98 | } 99 | } 100 | 101 | // MARK: Internal 102 | 103 | extension LoginDataSource { 104 | func field(at indexPath: IndexPath) -> FieldId? { 105 | guard let itemIdentifier = gridSource.itemIdentifier(for: indexPath) else { return nil } 106 | return FieldId(rawValue: itemIdentifier) 107 | } 108 | } 109 | 110 | private extension LoginDataSource { 111 | func prepareFields() { 112 | fieldIds.removeAll() 113 | fieldsMap.removeAll() 114 | 115 | var fields: [FieldModel] = [] 116 | fields.append({ 117 | let model = FormTextModel( 118 | id: FieldId.info.rawValue, 119 | title: NSLocalizedString("Announcement", comment: ""), 120 | value: NSLocalizedString("System will be offline tonight for maintenance, from midnight to 6 AM. Please submit your work before that.", comment: "") 121 | ) 122 | model.customSetup = { label in 123 | label.textColor = .blue 124 | label.superview?.backgroundColor = .clear // sneaky little hack 125 | } 126 | return model 127 | }()) 128 | 129 | fields.append({ 130 | let model = TextFieldModel( 131 | id: FieldId.username.rawValue, 132 | title: NSLocalizedString("Username", comment: ""), 133 | value: user?.username 134 | ) 135 | model.customSetup = { textField in 136 | textField.textContentType = .username 137 | } 138 | model.valueChanged = { [weak self] string, _ in 139 | self?.user?.username = string 140 | model.value = string 141 | } 142 | return model 143 | }()) 144 | 145 | fields.append({ 146 | let model = TextFieldModel( 147 | id: FieldId.password.rawValue, 148 | title: NSLocalizedString("Password", comment: ""), 149 | value: user?.password 150 | ) 151 | model.customSetup = { textField in 152 | textField.textContentType = .password 153 | textField.isSecureTextEntry = true 154 | } 155 | model.valueChanged = { [weak self] string, _ in 156 | self?.user?.password = string 157 | model.value = string 158 | } 159 | return model 160 | }()) 161 | 162 | fields.append({ 163 | let model = FieldModel(id: FieldId.forgotpassword.rawValue) 164 | return model 165 | }()) 166 | 167 | fields.append({ 168 | let model = FormButtonModel( 169 | id: FieldId.submit.rawValue, 170 | title: NSLocalizedString("Sign in", comment: "") 171 | ) 172 | model.action = realSubmit 173 | return model 174 | }()) 175 | 176 | fieldIds = fields.map { $0.id } 177 | fields.forEach { fieldsMap[$0.id] = $0 } 178 | } 179 | 180 | func realSubmit() { 181 | // validate 182 | // submit to middleware/data 183 | 184 | // in essence: re-run `prepareFields()` and update `fields` array 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /demo-app/RegisterController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class RegisterController: FieldsCollectionController { 4 | // View lifecycle 5 | 6 | override func viewDidLoad() { 7 | super.viewDidLoad() 8 | 9 | setupUI() 10 | applyTheme() 11 | } 12 | } 13 | 14 | 15 | 16 | private extension RegisterController { 17 | // MARK:- Internal 18 | 19 | func applyTheme() { 20 | view.backgroundColor = .systemBackground 21 | } 22 | 23 | func setupUI() { 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /demo-app/ViewModels/InventoryCategory-Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InventoryCategory-Extensions.swift 3 | // Fields-demo 4 | // 5 | // Created by Aleksandar Vacić on 8/11/19. 6 | // Copyright © 2019 Radiant Tap. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension InventoryCategory { 12 | var icon: UIImage { 13 | switch id { 14 | case .watch: 15 | return UIImage(imageLiteralResourceName: "icon-watch") 16 | case .dress: 17 | return UIImage(imageLiteralResourceName: "icon-dress") 18 | case .handbag: 19 | return UIImage(imageLiteralResourceName: "icon-handbag") 20 | case .makeup: 21 | return UIImage(imageLiteralResourceName: "icon-makeup") 22 | case .underwear: 23 | return UIImage(imageLiteralResourceName: "icon-underwear") 24 | } 25 | } 26 | } 27 | 28 | --------------------------------------------------------------------------------