Documentation
Form
A Form
in Astro Forms maintains a collection of rows, responds to row changes, and validates rows.
Creating a Form
To create a form, simply subclass Form
.
import AstroForms
class LoginForm: Form {
// ...
}
As Form
is a subclass of UIView
, just add the class in Interface Builder to add it to your UIViewController
.
Adding a Row
Forms have add(_:)
and insert(_: at:)
instance methods for adding rows to a form.
public func add(_ row: AnyRow)
public func insert(_ row: AnyRow, at index: Int)
To add a row, simply call the add/insert method at any point, for example:
import AstroForms
class LoginForm: Form {
enum LoginFormTag: RowTag, Equatable {
case fullName
}
override func awakeFromNib() {
super.awakeFromNib()
let fullNameRow = TextFieldRow(tag: LoginFormTag.fullName) {
$0.view.label.text = "Full Name"
$0.view.textField.placeholder = "Enter your name..."
}
// Adding the row here
add(fullNameRow)
}
}
Once added, these rows are available via the rows
property, an instance variable of type [AnyRow]
.
Row Tags in Forms
In the above example, you'll notice when creating fullNameRow
we used a LoginFormTag
enumerated type defined in LoginForm
.
Tags are an important concept in Astro Forms:
- Tags provide a unique ID for every
Row
- Tags can be dynamic using associated values
- Every
Row
must have a tag - Every
Form
has an enum that declares it's set of row tags.
Create tags by implementing RowTag
and Equatable
. For example a set of tags might be:
enum LoginFormTag: RowTag, Equatable {
case email, password, submit
}
A Form
's row tags type must implement Equatable
so they can be compared with each other.
Finding a Row
Because every row has a tag you can access them later with the findRow(tag:)
instance method:
let fullNameRow: TextFieldRow? = self.findRow(tag: LoginFormTag.fullName)
If rows are rendered dynamically, associated types provide a convenient way to declare tags for rows that may be generated base on conditions.
enum LoginFormTag: RowTag, Equatable {
case dynamicTag(String)
}
// Later, inside an instance method:
for i in 1...4 {
add(TextFieldRow(tag: LoginFormTag.dynamicTag("address-line-\(i)")))
}
let foundRow: TextFieldRow? = self.findRow(
tag: LoginFormTag.dynamicTag("address-line-4")
)
TIP
Only use dynamic row tags when absolutely required to avoid runtime errors from mistyped strings.
Removing a Row
A Form contains an remove function that will remove a row, and any associated views from the Form
.
public func remove(at index: Int)
Responding to Row Changes
A Form
receives all Row updates in a overrideable method:
func rowUpdate(type: RowUpdate, row: AnyRow)
There are several RowUpdate types, that map to common validation requirements, however can be used for any arbitrary form update (for example show/hide).
These are:
- live
rowUpdate
called every time the row is changed - onResignActive
rowUpdate
called every time the row is blurred - onResignActiveAfterChange
rowUpdate
called on blur after the row has changed at least once - regular
rowUpdate
called every time the row is changed after the row has been blurred at least once, and every time it is blurred
By example, to update the disabled state of a row that contains a button on every row change (ButtonRow):
override func rowUpdate(type: RowUpdate, row: AnyRow) {
guard let tag = row.tag as? LoginFormTag else { return }
switch type {
case .live:
// When any row is updated, update button row validity
guard let buttonRow: ButtonRow = findRow(
tag: LoginFormTag.submit
) else {
return
}
updateButtonEnabledStateUI(row: buttonRow)
default: break
}
}
Row
A Row in Astro Forms is a class that manages any UIView
that is inside a Form
. This is commonly a view with an input like a UITextField
, but it also might just be a view with your company logo, some images, a map - anything.
Creating a Row
To be added to a Form
a Row
only needs implement the Row
protocol. The requirements for this protocol are:
- To define a
UIView
type associated with theRow
- Add an property to refer to the
UIView
for this row - Add a
tag
property to store this row'sRowTag
Here's an example for a row that shows a company logo:
class CompanyLogoRow: Row {
// Define the view type
associatedtype View = CompanyLogoView
// The `UIView instance the user will see
var view: View
// Every Row must have a RowTag
var tag: RowTag
init(tag: RowTag, config: ((CompanyLogoRow) -> Void)? = nil) {
let view: View = View.fromXib()
self.view = view
self.tag = tag
config?(self)
}
}
In this example:
- The
CompanyLogoRow
includes a view, tag, and a basic initialization method - A
CompanyLogoView
(defined somewhere else), is just a UIView - A Xib for the
CompanyLogoView
That's it, this row is ready to be added to a Form
with the add(_:)
instance method.
Value Rows
A row can have a value of any type, simply by implementing the ValueRow
protocol. This protocol contains a value
instance property for getting and setting the value from the underlying UIView
. In the below example this is a Boolean based on a UISwitch
state, for a UITextField
based row, it would get and set the String text
property on the field.
This protocol also contains some instance properties and methods (with default implementations) for delegating view updates to the Form
.
By example, a SwitchRow (containing a UISwitch
) with a value:
import UIKit
import AstroForms
class SwitchRow: Row, ValueRow {
// ValueRow protocol requirements
typealias Value = Bool
var valueHasStartedEditing: Bool = false
var valueHasChanged: Bool = false
var valueHasEndedEditing: Bool = false
var value: Value {
get { return view.switch.isOn }
set { view.switch.setOn(newValue, animated: false) }
}
// Row protocol requirements
var tag: RowTag
var view: SwitchRowView
init(tag: RowTag, config: ((SwitchRow) -> Void)? = nil) {
...
}
}
In implementing ValueRow
for this SwitchRow
:
First, we set the Value typealias to a Bool (for when the switch is on or off)
Next, we create a virtual property that gets / sets the switch value, defined in the
SwitchRowView
Finally, there are three variables
valueHasStartedEditing
,valueHasChanged
,valueHasEndedEditing
. These are to manage the validation state for this row. To satisfy the protocol, just assign these default values offalse
.
Now, in the Form
, we have access to the row, with a Bool value:
// Inside the Form subclass
let row: SwitchRow? = findRow(tag: MyFormTag.mySwitchRow)
let value: Bool? = row?.value
Responding to View Changes
When the value of a ValueRow
changes, the ValueRow
automatically passes this change information up to a Form
. This update is then available in the Form's rowUpdate(type: RowUpdate, row: AnyRow)
.
However, given a Row's view can be any UIView
implementation, these changes could take many shapes. For example, a UISwitch
notifies a UIView
of updates using the target-action pattern forUIControl.Event.valueChanged
, however a UITextField uses a combination of delegate methods and the target-action pattern for UIControl.Event.editingChanged
.
For this reason, a ValueRow
normalizes all changes through several instance methods to call from your view.
// The value of the view has changed
func valueDidEdit()
// The use has started editing the view, relevant for UITextField etc...
func valueDidStartEditing()
// The user is done editing. This is the correct method for views that have
// instantly changing values too - like a `UISwitch` or `UIButton`
func valueDidEndEditing()
Call these on a row from the row's UIView implementation, for example:
class SwitchRowView: UIView {
@IBOutlet weak var `switch`: UISwitch!
@IBOutlet weak var label: UILabel!
// Store a weak reference to a row so you can call methods on it.
// This can be assigned during Row initialization.
weak var row: SwitchRow?
override func awakeFromNib() {
super.awakeFromNib()
`switch`.addTarget(
self,
action: #selector(switchValueChanged(_:)),
for: .valueChanged
)
}
@objc func switchValueChanged(_ sender: UISwitch) {
// Call the row's valueDidEndEditing() method,
// so it can pass this event up for validation etc.
row?.valueDidEndEditing()
}
}
Focusable Rows
Astro Forms exposes a FocusableRow
protocol to:
- Enable switching between focusable rows (like text inputs) with next/previous buttons
- Allow rows to define custom a
focusRect
. A focusRect is theCGRect
that you want form'sUIScrollView
to ensure is visible on screen when an input is focused. This is useful for ensuring for example, both email and password are visible above the keyboard when the email field is focused. No more hunting behind the keyboard for fields.
To implement this protocol:
- Use a block to return the
UIResponder
that should be focused when aRow
is asked to take focus - for example when the "Next" button on the keyboard is tapped on the previousRow
.
class TextFieldRow: Row, ValueRow, FocusableRow {
// ...
var focusElement: UIResponder { return view.textField }
// ...
}
- Provide a default implementation for focusRect to satisfy the protocol. If this is nil, the
Form
will focus theCGRect
for the wholeRow
. It's almost always best to set this to nil, and provide custom focusRect's when adding rows (so you can reference other rows etc).
var focusRect: () -> CGRect? = { return nil }
Later when adding rows in your Form, override this block:
emailRow.focusRect = {
CGRect(
x: emailRow.baseView.frame.origin.x,
y: emailRow.baseView.frame.origin.y,
width: emailRow.baseView.frame.width,
height: emailRow.baseView.frame.height + passwordRow.baseView.frame.height
)
}
Now when the email row UITextField
is the firstResponder
, both the emailRow and passwordRow will be visible above the keyboard.
Helpers
A Helper is a reusable UIView
that a Row manages that appears after the Row's UIView
in a UIStackView
. A clear use case for a Helper is error message views that need to show and hide on validation errors. The interface between a Row
and a helper is the ability to show and hide the helper.
This is done through two methods:
func showHelper<T: UIView>(
viewType: T.Type,
animated: Bool,
config: ((T) -> Void)?
)
func hideHelper(
animated: Bool = false,
callback: (() -> Void)? = nil
)
By example, showing an error using the example helper ErrorView
is as simple as calling the show method, and configuring the view:
guard validate(row: row, ValidationRule.required) else {
row.showHelper(viewType: ErrorView.self, animated: true) { errorView in
errorView.label.text = "This field is required"
}
return
}
The only unusual thing here is the use of the class type instead of an existing instance as a parameter when showing a helper. This is to avoid the need to store instances of helper views - Astro Forms will manage replacing the view if necessary, or reusing the existing view if it is of the same type.
Validation
Validation is handled by several instance methods available on Form
, and does not enforce any particular architecture.
However, as in the example project, the logical place is to handle validation in methods called from func rowUpdate(type: RowUpdate, row: AnyRow)
on the form.
Validation Methods
There are several validation methods available on a form for validating input, with method signatures all beginning validate
.
Basic Validation
The simplest kind of validation takes a variadic parameter of blocks that return boolean values. If all the blocks return true, then the result is true. Each block is passed the row value as it's parameter.
func validate<R: ValueRow>(
row: R,
_ rules: ValidationRuleBlock<R>...) -> Bool
By example, consider a StringRow
with a value of string:
let isValid: Bool = form.validate(
row: stringRow,
{ $0 == "astro" }, // $0 is the value of the stringRow
{ $0.count == 5 } // typed to String based on the Row's Value associatedtype
)
Here, two blocks are passed in, one comparing the StringRow
value to "astro", and another counting the characters.
Validation Messages
If there are multiple rules being used in a chain, it can be helpful to use a tuple with messages, so rather than just know the chain failed overall, you also have a message for the specific rule that failed.
func validate<R: ValueRow>(
row: R,
_ rules: ValidationRuleMsgBlock<R>...) -> (Bool, String?)
By example, the following will return (false, "The character count must be correct")
, for the input string "astro":
let isValid = form.validate(
row: stringRow,
({ $0 == "astro" }, "The string should equal astro"),
({ $0.count == 10 }, "The character count must be correct")
)
Validation List
In some situations, you may want to return a full list of messages and true / false for those that pass or fail. A common use case for the structure is a password field with many rules, and you want to show the user which ones they have successfully filled, and those they have yet to pass.
func validateList<R: ValueRow>(
row: R,
_ rules: ValidationRuleMsgBlock<R>...) -> [(Bool, String?)]
By example, with a password list:
// Where stringRow.value == "astroforms"
let passwordValidityList = form.validateList(
row: stringRow,
({$0 == "*" }, "The password should contain a *."),
({$0.count > 6}, "The password should have more than 6 characters."),
({$0.count < 30}, "The password should have less than 30 characters.")
)
// Returns:
// [(false, "The password should contain a *."),
// (true, "The password should have more than 6 characters."),
// (true, "The password should have less than 30 characters.")]
Named Validation Rules
Inline blocks are convenient however not very reusable. Astro Forms contains a factory of ValidationRule
methods, these can be mixed with inline blocks. This can be extended with your own methods via an extension.
let isValid: Bool = validate(
row: stringRow,
{ $0.count > 0 },
ValidationRule.isEmail
)
Theming
You can create themes in a type safe way easily in Astro Forms for any kind of property. Themes are defined on a form and inherited by views automatically, although any given instance of a row can override the theme to allow multiple themes to be used in the same form.
Theming in Astro Forms can be used independently of forms too, you can apply the Themeable
protocol you create to any UIView
, simply by implementing the theme
instance property and updating themes when you set it. If the given UIView
is in a subview of any Form
, it won't inherit a theme automatically so you will need to define it on each UIView
.
Here an example UIImageView
subclass that implements a different backgroundImage per theme in the Astro Forms example project:
class ThemeableImageView: UIImageView, Themeable {
var theme: AstroTheme? {
didSet { updateTheme() }
}
func updateTheme() { self.image = image(.formBackground) }
}
Creating a Theme
To create a theme, you need to create your own Themeable
protocol, composed of various theming protocols that define themable behaviour (color, size) and also those that define how to apply the theme to your UIView
subclasses (Form
or Row
views). These are constrained to concrete enumerated types implementing:
- The number and names of the different themes in your project (for example: light, dark etc.)
- The properties that should be applied within the themes (primaryColor, smallMarginSize etc.)
This then exposes easy to use methods with a convenience syntax in any UIView that inherits your new protocol.
Included Themable Protocols
The color function is exposed by ThemeableColorTraits
:
func color(_ requirement: ThemeColorType) -> UIColor
An example usage is: textView.tintColor = color(.primaryTint)
,
The image function is exposed by ThemeableImageTraits
:
func image(_ requirement: ThemeImageType) -> UIImage
An example usage is: imageView.image = image(.formBackground)
.
In both these cases, a color and image will be returned for the currently active theme.