├── .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 | 
2 | [](https://github.com/radianttap/Coordinator/blob/master/LICENSE)
3 | 
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 |
--------------------------------------------------------------------------------