├── .gitignore
├── Docs
└── Images
│ ├── Display.gif
│ ├── Event.gif
│ ├── ForEach.png
│ ├── ForEach2.png
│ ├── Hidden.gif
│ └── Profile.gif
├── LICENSE
├── Package.swift
├── README.md
├── Sources
├── Array+ListConvertible.swift
├── ForEach.swift
├── Form.swift
├── FormDataSource.swift
├── FormViewController.swift
├── ListBuilder.swift
├── Row.swift
└── Section.swift
└── Tests
├── FormUITests
├── FormUITests.swift
└── XCTestManifests.swift
└── LinuxMain.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | .swiftpm/
7 |
--------------------------------------------------------------------------------
/Docs/Images/Display.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/james01/FormUI/5f877f6d25c51384a1ef990a47ffc4e04c6a49d5/Docs/Images/Display.gif
--------------------------------------------------------------------------------
/Docs/Images/Event.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/james01/FormUI/5f877f6d25c51384a1ef990a47ffc4e04c6a49d5/Docs/Images/Event.gif
--------------------------------------------------------------------------------
/Docs/Images/ForEach.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/james01/FormUI/5f877f6d25c51384a1ef990a47ffc4e04c6a49d5/Docs/Images/ForEach.png
--------------------------------------------------------------------------------
/Docs/Images/ForEach2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/james01/FormUI/5f877f6d25c51384a1ef990a47ffc4e04c6a49d5/Docs/Images/ForEach2.png
--------------------------------------------------------------------------------
/Docs/Images/Hidden.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/james01/FormUI/5f877f6d25c51384a1ef990a47ffc4e04c6a49d5/Docs/Images/Hidden.gif
--------------------------------------------------------------------------------
/Docs/Images/Profile.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/james01/FormUI/5f877f6d25c51384a1ef990a47ffc4e04c6a49d5/Docs/Images/Profile.gif
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 James Randolph
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "FormUI",
7 | platforms: [
8 | .iOS(.v13)
9 | ],
10 | products: [
11 | .library(
12 | name: "FormUI",
13 | targets: ["FormUI"]),
14 | ],
15 | targets: [
16 | .target(
17 | name: "FormUI",
18 | path: "Sources"),
19 | .testTarget(
20 | name: "FormUITests",
21 | dependencies: ["FormUI"]),
22 | ]
23 | )
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FormUI
2 |
3 | 
4 |
5 | Powerfully simple form builder for UIKit.
6 |
7 | ---
8 |
9 | ## Overview
10 |
11 | FormUI provides an easy way to build native forms for iOS. It is inspired by SwiftUI and takes advantage of new technologies like Combine and result builders. However, it is entirely implemented in UIKit which gives it more options for customization.
12 |
13 | FormUI aims to be lightweight and unopinionated. It solves the troublesome parts of making forms—like counting sections and hiding rows—but leaves you to fill in the details for your specific use case.
14 |
15 | ---
16 |
17 | ## Examples
18 |
19 | |  |  |  |
20 | | :---------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------: |
21 |
22 | ---
23 |
24 | ## Requirements
25 |
26 | - iOS 13.0+
27 | - Swift 5.3+
28 |
29 | ---
30 |
31 | ## Installation
32 |
33 | ### Swift Package Manager
34 |
35 | To install FormUI using the [Swift Package Manager](https://swift.org/package-manager/), add the following value to your `Package.swift`:
36 |
37 | ```swift
38 | dependencies: [
39 | .package(url: "https://github.com/james01/FormUI.git", .upToNextMajor(from: "0.1.1"))
40 | ]
41 | ```
42 |
43 | ---
44 |
45 | ## Usage
46 |
47 | ### Creating a Form
48 |
49 | Create a form by subclassing `FormViewController`.
50 |
51 | ```swift
52 | import FormUI
53 |
54 | class MyFormViewController: FormViewController {
55 |
56 | convenience init() {
57 | self.init(style: .grouped)
58 | }
59 |
60 | override func viewDidLoad() {
61 | super.viewDidLoad()
62 | form = Form {
63 | Section {
64 | Row(style: .default) { (cell) in
65 | cell.textLabel?.text = "Hello World"
66 | }
67 | }
68 | }
69 | }
70 | }
71 | ```
72 |
73 | > The `Form` and `Section` initializers take advantage of the _result builder_ feature of Swift that was unofficially introduced in Swift 5.1, and officially introduced in Swift 5.4.
74 |
75 | ### Handling Row Selection
76 |
77 | Handle row selection by passing a handler to the `onSelect(_:)` method of a row. This handler will be called every time the row is selected.
78 |
79 | ```swift
80 | Row(style: .default) { (cell) in
81 | cell.textLabel?.text = "Tap Me"
82 | }.onSelect { (tableView, indexPath) in
83 | tableView.deselectRow(at: indexPath, animated: true)
84 | print("Hello World")
85 | }
86 | ```
87 |
88 | ### Dynamically Hiding and Showing Rows
89 |
90 | FormUI makes it easy to hide and show rows. This is an otherwise complicated task when using the default `UITableView`.
91 |
92 | First, define a published variable to keep track of whether the row is hidden.
93 |
94 | ```swift
95 | @Published var isRowHidden = true
96 | ```
97 |
98 | Then, pass the projected value of that variable (i.e., `$isRowHidden`) to the `hidden(_:)` method on the row you want to hide. Every time `isRowHidden` changes its value, the row will automatically show or hide itself.
99 |
100 | > For more information on the `Published` property wrapper, see its [documentation](https://developer.apple.com/documentation/combine/published).
101 |
102 | ```swift
103 | Section {
104 | Row(style: .default) { (cell) in
105 | cell.textLabel?.text = "Tap to Toggle Row"
106 | }.onSelect { (tableView, indexPath) in
107 | tableView.deselectRow(at: indexPath, animated: true)
108 | self.isRowHidden.toggle()
109 | }
110 |
111 | Row(style: .default) { (cell: UITableViewCell) in
112 | cell.textLabel?.text = "Hidden"
113 | }.hidden($isRowHidden)
114 |
115 | Row(style: .default) { (cell) in
116 | cell.textLabel?.text = "Not Hidden"
117 | }
118 | }
119 | ```
120 |
121 |
122 |
123 | You can hide and show sections in a similar way.
124 |
125 | ### Using `ForEach`
126 |
127 | Oftentimes you'll want to generate rows or sections from a static data source such as an enum. Use `ForEach` in these situations.
128 |
129 | ```swift
130 | enum Theme: String, CaseIterable {
131 | case light
132 | case dark
133 | case system
134 | }
135 |
136 | ...
137 |
138 | Section(header: "Theme") {
139 | ForEach(Theme.self) { (theme) -> Row in
140 | Row(style: .default) { (cell) in
141 | cell.textLabel?.text = theme.rawValue.capitalized
142 | }
143 | }
144 | }
145 | ```
146 |
147 |
148 |
149 | `ForEach` can also be used with an array of data.
150 |
151 | ```swift
152 | let desserts = [
153 | "🍪 Cookies",
154 | "🍫 Chocolate",
155 | "🍦 Ice Cream",
156 | "🍭 Candy",
157 | "🍩 Doughnuts",
158 | "🍰 Cake"
159 | ]
160 |
161 | ...
162 |
163 | Section(header: "Desserts") {
164 | ForEach(0.. Row in
165 | Row(style: .default) { (cell) in
166 | cell.textLabel?.text = desserts[n]
167 | }
168 | }
169 | }
170 | ```
171 |
172 |
173 |
174 | ---
175 |
176 | ## Author
177 |
178 | James Randolph ([@jamesrandolph01](https://twitter.com/jamesrandolph01))
179 |
180 | ---
181 |
182 | ## License
183 |
184 | FormUI is released under the MIT license. See [LICENSE](LICENSE) for details.
185 |
--------------------------------------------------------------------------------
/Sources/Array+ListConvertible.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Array+ListConvertible.swift
3 | //
4 | //
5 | // Created by James Randolph on 2/1/21.
6 | //
7 |
8 | import Foundation
9 |
10 | extension Array: ListConvertible {
11 | public func asList() -> [Element] {
12 | return self
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/ForEach.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ForEach.swift
3 | //
4 | //
5 | // Created by James Randolph on 2/1/21.
6 | //
7 |
8 | import Foundation
9 |
10 | /// A structure that computes items from a static data source, such as an enum.
11 | public struct ForEach {
12 |
13 | let items: [T]
14 |
15 | /// Creates an instance from a range of values. These values can be used to index an array, for example.
16 | /// - Parameters:
17 | /// - data: The data the `ForEach` instance uses to create items.
18 | /// - content: A closure that creates items.
19 | public init(_ data: Range, content: (Int) -> T) {
20 | self.items = data.map(content)
21 | }
22 |
23 | /// Creates an instance from a type conforming to `CaseIterable`, often an enum.
24 | /// - Parameters:
25 | /// - data: The data the `ForEach` instance uses to create items.
26 | /// - content: A closure that creates items.
27 | public init(_ data: Data.Type, content: (Data) -> T) {
28 | self.items = data.allCases.map(content)
29 | }
30 | }
31 |
32 | extension ForEach: ListConvertible {
33 | public func asList() -> [T] {
34 | return items
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Sources/Form.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Form.swift
3 | //
4 | //
5 | // Created by James Randolph on 1/26/21.
6 | //
7 |
8 | import UIKit
9 |
10 | /// A representation of a form that can be displayed in a table view.
11 | public final class Form {
12 |
13 | let sections: [Section]
14 |
15 | var rowAnimation: UITableView.RowAnimation = .fade
16 |
17 | var headerView: UIView?
18 |
19 | var footerView: UIView?
20 |
21 | var rowHeight: CGFloat?
22 |
23 | var estimatedRowHeight: CGFloat?
24 |
25 | var cellBackgroundColor: UIColor?
26 |
27 | /// Initializes a form with the given content.
28 | /// - Parameter content: The content of the form.
29 | public init(@ListBuilder content: () -> [Section]) {
30 | self.sections = content()
31 | }
32 |
33 | /// Sets the animation to use when adding or removing rows. By default, the form uses a fade animation.
34 | /// - Parameter rowAnimation: The animation to use when adding or removing rows.
35 | public func rowAnimation(_ rowAnimation: UITableView.RowAnimation) -> Self {
36 | self.rowAnimation = rowAnimation
37 | return self
38 | }
39 |
40 | /// Sets the header view of the underlying table view.
41 | /// - Parameter headerView: The header view.
42 | public func headerView(_ headerView: UIView) -> Self {
43 | self.headerView = headerView
44 | return self
45 | }
46 |
47 | /// Sets the footer view of the underlying table view.
48 | /// - Parameter footerView: The footer view.
49 | public func footerView(_ footerView: UIView) -> Self {
50 | self.footerView = footerView
51 | return self
52 | }
53 |
54 | /// Sets the row height of the underlying table view.
55 | /// - Parameter rowHeight: The row height.
56 | public func rowHeight(_ rowHeight: CGFloat) -> Self {
57 | self.rowHeight = rowHeight
58 | return self
59 | }
60 |
61 | /// Sets the estimated row height of the underlying table view.
62 | /// - Parameter estimatedRowHeight: The estimated row height.
63 | public func estimatedRowHeight(_ estimatedRowHeight: CGFloat) -> Self {
64 | self.estimatedRowHeight = estimatedRowHeight
65 | return self
66 | }
67 |
68 | /// Sets the background color to apply to all cells in the form.
69 | /// - Parameter cellBackgroundColor: The background color to apply to all cells.
70 | public func cellBackgroundColor(_ cellBackgroundColor: UIColor) -> Self {
71 | self.cellBackgroundColor = cellBackgroundColor
72 | return self
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/Sources/FormDataSource.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FormDataSource.swift
3 | //
4 | //
5 | // Created by James Randolph on 2/20/21.
6 | //
7 |
8 | import UIKit
9 | import Combine
10 |
11 | final class FormDataSource: UITableViewDiffableDataSource {
12 |
13 | typealias Snapshot = NSDiffableDataSourceSnapshot
14 |
15 | let form: Form
16 |
17 | private var subscriptions = Set()
18 |
19 | init(tableView: UITableView, form: Form) {
20 | self.form = form
21 | super.init(tableView: tableView) { (_, _, row) -> UITableViewCell? in
22 | if let cellBackgroundColor = form.cellBackgroundColor {
23 | row.cell.backgroundColor = cellBackgroundColor
24 | }
25 | return row.cell
26 | }
27 |
28 | defaultRowAnimation = form.rowAnimation
29 | if let headerView = form.headerView {
30 | tableView.tableHeaderView = headerView
31 | }
32 | if let footerView = form.footerView {
33 | tableView.tableFooterView = footerView
34 | }
35 | if let rowHeight = form.rowHeight {
36 | tableView.rowHeight = rowHeight
37 | }
38 | if let estimatedRowHeight = form.estimatedRowHeight {
39 | tableView.estimatedRowHeight = estimatedRowHeight
40 | }
41 | }
42 |
43 | override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
44 | let index = snapshot().sectionIdentifiers[section]
45 | return form.sections[index].header
46 | }
47 |
48 | override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
49 | let index = snapshot().sectionIdentifiers[section]
50 | return form.sections[index].footer
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Sources/FormViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FormViewController.swift
3 | //
4 | //
5 | // Created by James Randolph on 1/21/21.
6 | //
7 |
8 | import UIKit
9 | import Combine
10 |
11 | /// A view controller that displays a form.
12 | open class FormViewController: UITableViewController {
13 |
14 | private var dataSource: FormDataSource!
15 |
16 | private var subscriptions = Set()
17 |
18 | /// The abstract representation of the form managed by the controller object.
19 | public var form: Form! {
20 | didSet {
21 | dataSource = FormDataSource(tableView: tableView, form: form)
22 | applySnapshot(animated: false)
23 | Publishers.MergeMany(form.sections.compactMap({ $0.hiddenChanged }))
24 | .merge(with: Publishers.MergeMany(form.sections.map({ Publishers.MergeMany($0.rows.compactMap({ $0.hiddenChanged })) })))
25 | .sink { _ in
26 | self.applySnapshot(animated: true)
27 | }.store(in: &subscriptions)
28 | }
29 | }
30 |
31 | private func applySnapshot(animated: Bool) {
32 | var snapshot = FormDataSource.Snapshot()
33 | let enumeratedSections = form.sections.enumerated().filter({ !$0.element.hidden && $0.element.rows.contains(where: { !$0.hidden }) })
34 | snapshot.appendSections(enumeratedSections.map({ $0.offset }))
35 | for (n, section) in enumeratedSections {
36 | snapshot.appendItems(section.rows.filter({ !$0.hidden }), toSection: n)
37 | }
38 | dataSource.apply(snapshot, animatingDifferences: animated)
39 | }
40 |
41 | open override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
42 | let sectionIndex: Int
43 | if #available(iOS 15.0, *) {
44 | sectionIndex = dataSource.sectionIdentifier(for: section)!
45 | } else {
46 | sectionIndex = dataSource.snapshot().sectionIdentifiers[section]
47 | }
48 | return form.sections[sectionIndex].headerHeight
49 | }
50 |
51 | open override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
52 | let sectionIndex: Int
53 | if #available(iOS 15.0, *) {
54 | sectionIndex = dataSource.sectionIdentifier(for: section)!
55 | } else {
56 | sectionIndex = dataSource.snapshot().sectionIdentifiers[section]
57 | }
58 | return form.sections[sectionIndex].footerHeight
59 | }
60 |
61 | open override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
62 | return dataSource.itemIdentifier(for: indexPath)?.height ?? tableView.rowHeight
63 | }
64 |
65 | open override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
66 | dataSource.itemIdentifier(for: indexPath)?.onSelect?(tableView, indexPath)
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/Sources/ListBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ListBuilder.swift
3 | //
4 | //
5 | // Created by James Randolph on 2/1/21.
6 | //
7 |
8 | import Foundation
9 |
10 | /// Types adopting `ListConvertible` can be represented as an array of elements.
11 | public protocol ListConvertible {
12 | associatedtype Element
13 |
14 | /// Returns an array representation of the conforming instance.
15 | func asList() -> [Element]
16 | }
17 |
18 | /// A custom parameter attribute that constructs arrays from closures.
19 | @_functionBuilder public struct ListBuilder {
20 | public static func buildBlock() -> [T] {
21 | return []
22 | }
23 |
24 | public static func buildBlock(_ content: Content) -> [T] where Content: ListConvertible, Content.Element == T {
25 | return content.asList()
26 | }
27 |
28 | public static func buildBlock(_ c0: C0, _ c1: C1) -> [T] where C0: ListConvertible, C1: ListConvertible, C0.Element == T, C1.Element == T {
29 | return [c0.asList(), c1.asList()].flatMap({ $0 })
30 | }
31 |
32 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2) -> [T] where C0: ListConvertible, C1: ListConvertible, C2: ListConvertible, C0.Element == T, C1.Element == T, C2.Element == T {
33 | return [c0.asList(), c1.asList(), c2.asList()].flatMap({ $0 })
34 | }
35 |
36 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3) -> [T] where C0: ListConvertible, C1: ListConvertible, C2: ListConvertible, C3: ListConvertible, C0.Element == T, C1.Element == T, C2.Element == T, C3.Element == T {
37 | return [c0.asList(), c1.asList(), c2.asList(), c3.asList()].flatMap({ $0 })
38 | }
39 |
40 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4) -> [T] where C0: ListConvertible, C1: ListConvertible, C2: ListConvertible, C3: ListConvertible, C4: ListConvertible, C0.Element == T, C1.Element == T, C2.Element == T, C3.Element == T, C4.Element == T {
41 | return [c0.asList(), c1.asList(), c2.asList(), c3.asList(), c4.asList()].flatMap({ $0 })
42 | }
43 |
44 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5) -> [T] where C0: ListConvertible, C1: ListConvertible, C2: ListConvertible, C3: ListConvertible, C4: ListConvertible, C5: ListConvertible, C0.Element == T, C1.Element == T, C2.Element == T, C3.Element == T, C4.Element == T, C5.Element == T {
45 | return [c0.asList(), c1.asList(), c2.asList(), c3.asList(), c4.asList(), c5.asList()].flatMap({ $0 })
46 | }
47 |
48 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6) -> [T] where C0: ListConvertible, C1: ListConvertible, C2: ListConvertible, C3: ListConvertible, C4: ListConvertible, C5: ListConvertible, C6: ListConvertible, C0.Element == T, C1.Element == T, C2.Element == T, C3.Element == T, C4.Element == T, C5.Element == T, C6.Element == T {
49 | return [c0.asList(), c1.asList(), c2.asList(), c3.asList(), c4.asList(), c5.asList(), c6.asList()].flatMap({ $0 })
50 | }
51 |
52 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7) -> [T] where C0: ListConvertible, C1: ListConvertible, C2: ListConvertible, C3: ListConvertible, C4: ListConvertible, C5: ListConvertible, C6: ListConvertible, C7: ListConvertible, C0.Element == T, C1.Element == T, C2.Element == T, C3.Element == T, C4.Element == T, C5.Element == T, C6.Element == T, C7.Element == T {
53 | return [c0.asList(), c1.asList(), c2.asList(), c3.asList(), c4.asList(), c5.asList(), c6.asList(), c7.asList()].flatMap({ $0 })
54 | }
55 |
56 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8) -> [T] where C0: ListConvertible, C1: ListConvertible, C2: ListConvertible, C3: ListConvertible, C4: ListConvertible, C5: ListConvertible, C6: ListConvertible, C7: ListConvertible, C8: ListConvertible, C0.Element == T, C1.Element == T, C2.Element == T, C3.Element == T, C4.Element == T, C5.Element == T, C6.Element == T, C7.Element == T, C8.Element == T {
57 | return [c0.asList(), c1.asList(), c2.asList(), c3.asList(), c4.asList(), c5.asList(), c6.asList(), c7.asList(), c8.asList()].flatMap({ $0 })
58 | }
59 |
60 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> [T] where C0: ListConvertible, C1: ListConvertible, C2: ListConvertible, C3: ListConvertible, C4: ListConvertible, C5: ListConvertible, C6: ListConvertible, C7: ListConvertible, C8: ListConvertible, C9: ListConvertible, C0.Element == T, C1.Element == T, C2.Element == T, C3.Element == T, C4.Element == T, C5.Element == T, C6.Element == T, C7.Element == T, C8.Element == T, C9.Element == T {
61 | return [c0.asList(), c1.asList(), c2.asList(), c3.asList(), c4.asList(), c5.asList(), c6.asList(), c7.asList(), c8.asList(), c9.asList()].flatMap({ $0 })
62 | }
63 |
64 | public static func buildIf(_ content: [T]?) -> [T] {
65 | return content ?? []
66 | }
67 |
68 | public static func buildEither(first: TrueContent) -> [T] where TrueContent: ListConvertible, TrueContent.Element == T {
69 | return first.asList()
70 | }
71 |
72 | public static func buildEither(second: TrueContent) -> [T] where TrueContent: ListConvertible, TrueContent.Element == T {
73 | return second.asList()
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Sources/Row.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Row.swift
3 | //
4 | //
5 | // Created by James Randolph on 1/21/21.
6 | //
7 |
8 | import UIKit
9 | import Combine
10 |
11 | /// A representation of a row in a table view.
12 | open class Row: Identifiable {
13 |
14 | let cell: UITableViewCell
15 |
16 | var onSelect: ((UITableView, IndexPath) -> Void)?
17 |
18 | var height: CGFloat?
19 |
20 | var hidden: Bool = false
21 |
22 | var hiddenChanged: PassthroughSubject?
23 |
24 | private var subscriptions = Set()
25 |
26 | /// Initializes a row with a cell of the given style.
27 | /// - Parameters:
28 | /// - style: The cell style with which to create the cell.
29 | /// - configure: The closure that configures the cell.
30 | public init(style: UITableViewCell.CellStyle, _ configure: ((UITableViewCell) -> Void)) {
31 | self.cell = UITableViewCell(style: style, reuseIdentifier: nil)
32 | configure(cell)
33 | }
34 |
35 | /// Initializes a row with the given cell.
36 | /// - Parameters:
37 | /// - cell: The cell with which to create the row.
38 | /// - configure: The closure that configures the cell.
39 | public init(cell: Cell, _ configure: ((Cell) -> Void)) {
40 | self.cell = cell
41 | configure(cell)
42 | }
43 |
44 | /// Adds an action to perform when the row is selected.
45 | /// - Parameter handler: The action to perform when the row is selected.
46 | public func onSelect(handler: @escaping (UITableView, IndexPath) -> Void) -> Self {
47 | self.onSelect = handler
48 | return self
49 | }
50 |
51 | /// Sets the height of the row.
52 | /// - Parameter height: The height to set for the row.
53 | public func height(_ height: CGFloat) -> Self {
54 | self.height = height
55 | return self
56 | }
57 |
58 | /// Shows or hides the row according to values emitted by a publisher.
59 | /// - Parameter hidden: The publisher to use to control whether the row is hidden.
60 | public func hidden(_ hidden: T) -> Self where T: Publisher, T.Output == Bool, T.Failure == Never {
61 | hiddenChanged = PassthroughSubject()
62 | hidden.sink { (hidden) in
63 | self.hidden = hidden
64 | self.hiddenChanged?.send()
65 | }.store(in: &subscriptions)
66 | return self
67 | }
68 | }
69 |
70 | extension Row: Hashable {
71 | public static func == (lhs: Row, rhs: Row) -> Bool {
72 | return lhs.id == rhs.id
73 | }
74 |
75 | public func hash(into hasher: inout Hasher) {
76 | hasher.combine(id)
77 | }
78 | }
79 |
80 | extension Row: ListConvertible {
81 | public func asList() -> [Row] {
82 | return [self]
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/Sources/Section.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Section.swift
3 | //
4 | //
5 | // Created by James Randolph on 1/21/21.
6 | //
7 |
8 | import UIKit
9 | import Combine
10 |
11 | /// A representation of a section in a table view.
12 | public final class Section {
13 |
14 | let header: String?
15 |
16 | let footer: String?
17 |
18 | let rows: [Row]
19 |
20 | var headerHeight: CGFloat = UITableView.automaticDimension
21 |
22 | var footerHeight: CGFloat = UITableView.automaticDimension
23 |
24 | var hidden: Bool = false
25 |
26 | var hiddenChanged: PassthroughSubject?
27 |
28 | private var subscriptions = Set()
29 |
30 | /// Initializes a section with the given header, footer, and rows.
31 | /// - Parameters:
32 | /// - header: The header for the section, if any.
33 | /// - footer: The footer for the section, if any.
34 | /// - content: The content of the section.
35 | public init(header: String? = nil, footer: String? = nil, @ListBuilder content: () -> [Row]) {
36 | self.header = header
37 | self.footer = footer
38 | self.rows = content()
39 | }
40 |
41 | /// Sets the height of the section header.
42 | /// - Parameter height: The height of the header.
43 | public func headerHeight(_ height: CGFloat) -> Self {
44 | self.headerHeight = height
45 | return self
46 | }
47 |
48 | /// Sets the height of the section footer.
49 | /// - Parameter height: The height of the footer.
50 | public func footerHeight(_ height: CGFloat) -> Self {
51 | self.footerHeight = height
52 | return self
53 | }
54 |
55 | /// Shows or hides the section according to values emitted by a publisher.
56 | /// - Parameter hidden: The publisher to use to control whether the section is hidden.
57 | public func hidden(_ hidden: T) -> Self where T: Publisher, T.Output == Bool, T.Failure == Never {
58 | hiddenChanged = PassthroughSubject()
59 | hidden.sink { (hidden) in
60 | self.hidden = hidden
61 | self.hiddenChanged?.send()
62 | }.store(in: &subscriptions)
63 | return self
64 | }
65 | }
66 |
67 | extension Section: ListConvertible {
68 | public func asList() -> [Section] {
69 | return [self]
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Tests/FormUITests/FormUITests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import FormUI
3 |
4 | final class FormUITests: XCTestCase {
5 | func testExample() {
6 | }
7 |
8 | static var allTests = [
9 | ("testExample", testExample),
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/Tests/FormUITests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(FormUITests.allTests),
7 | ]
8 | }
9 | #endif
10 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import FormUITests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | tests += FormUITests.allTests()
7 | XCTMain(tests)
8 |
--------------------------------------------------------------------------------