SwiftUI Tutorial -Creating a Style-able Address Form Component — Part 1
Hello everyone, With this post, I am starting a new series where I will create a style-able address form component in SwiftUI. This address form will be extensible, meaning it will be possible to create new styles. It will be possible to set the style globally. It will have validation and autocorrection for each different field type. It will also come with 4 built-in styles,
- default
- rounded
- floating
- capsule
This is how the entry fields will look like for the 4 different styles in order.
Let’s get started. To create our AddressForm
, first we need to create an EntryField
. So let’s start from there.
# Create a new project, choose SwiftUI and Swift, name it AddressForm
.
# Create a new SwiftUI file, name it EntryField.
EntryField
needs following properties:
- content (input view)
- leadingAccessoryView (home icon on the left side)
- trailingAccessoryView (info icon on the left side)
- string (value entered)
struct EntryField<Content: View, LeadingAccessoryView: View, TrailingAccessoryView: View>: View {
@State var value: String
let content: Content
let leadingAccessoryView: LeadingAccessoryView
let trailingAccessoryView: TrailingAccessoryView
var body: some View {
}
}
To be able to see this on preview, we can create an EntryFieldView
struct:
struct EntryFieldView: View {
@State var value: String = ""
var body: some View {
EntryField(value: value,
content: TextField("Street Address", text: $value),
leadingAccessoryView: Image(systemName: "house"),
trailingAccessoryView: Image(systemName: "info.circle"))
}
}
Return it from EntryField_Previews
:
struct EntryField_Previews: PreviewProvider {
static var previews: some View {
EntryFieldView()
}
}
Fill the body of the EntryField
:
var body: some View {
VStack {
HStack {
leadingAccessoryView
content
trailingAccessoryView
}
Spacer()
}
.padding()
}
}
Gray out the accessory views a little bit,
var body: some View {
VStack {
HStack {
leadingAccessoryView
.opacity(0.5)
content
trailingAccessoryView
.opacity(0.5)
}
Spacer()
}
.padding()
}
}
By now, we already have our default style entry field and we can see it in the preview:
Let’s see if our binding is working by adding an onChange
modifier to EntryFieldView
:
var body: some View {
EntryField(...)
.onChange(of: value) { newValue in
print(newValue)
}
}
Put EntryFieldView
into the body
of ContentView
, run the app on simulator and see that it is logging the new values while we type in the EntryField
.
struct ContentView: View {
var body: some View {
VStack {
EntryFieldView()
Spacer()
}
.padding()
}
}
# Making the EntryField
style-able
We have an EntryField
, which is good. But we want to make it style-able. We want to type something like:
EntryField(...)
.style(.capsule)
or
EntryField(...)
.style(.floating)
, and set the style globally. We also want to make it extensible so that we can add more styles in the future. To achieve this, we need to define an EntryFieldStyle
protocol.
With the help of this protocol, we can create different styles that conform to it. Like:
DefaultEntryFieldStyle: EntryFieldStyle
RoundedEntryFieldStyle: EntryFieldStyle
FloatingEntryFieldStyle: EntryFieldStyle
CapsuleEntryFieldStyle: EntryFieldStyle
When we create an EntryField
, we can pass it an EntryFieldStyle
, and EntryFieldStyleConfiguration
instance which will hold values needed to create an EntryField
. This way EntryField
can tell the a specific EntryFieldStyle
to create an EntryField
with an EntryFieldStyleConfiguration
. Let’s do it.
# Create a new struct named EntryFieldStyleConfiguration
:
public struct EntryFieldStyleConfiguration {
@State var value: String
let content: Content
let leadingAccessoryView: LeadingAccessoryView
let trailingAccessoryView: TrailingAccessoryView
struct Content: View {
var underlyingView: AnyView
var body: some View {
underlyingView
}
}
struct LeadingAccessoryView: View {
var underlyingView: AnyView
var body: some View {
underlyingView
}
}
struct TrailingAccessoryView: View {
var underlyingView: AnyView
var body: some View {
underlyingView
}
}
}
# Create a new protocol named EntryFieldStyle
:
public protocol EntryFieldStyle {
associatedtype Body: View
func makeBody(_ configuration: EntryFieldStyleConfiguration) -> Body
}
Now that we have a configuration struct and a style protocol, let’s create our first style, which will be the default style.
# Create a struct named DefaultEntryField
which will receive only the configuration as an argument:
struct DefaultEntryField: View {
let configuration: EntryFieldStyleConfiguration
var body: some View {
VStack {
HStack {
configuration.leadingAccessoryView
.opacity(0.5)
configuration.content
configuration.trailingAccessoryView
.opacity(0.5)
}
Spacer()
}
.padding()
}
}
# Create a struct named DefaultEntryFieldStyle
which conforms to EntryFieldStyle
:
struct DefaultEntryFieldStyle: EntryFieldStyle {
func makeBody(_ configuration: EntryFieldStyleConfiguration) -> some View {
DefaultEntryField(configuration: configuration)
}
}
Now we can pass the style to the initializer of EntryField
, which will create the specific style EntryField
for us.
We are ready to use our first style. Add style variable to EntryField
, and call style.makeBody
like this:
struct EntryField<Style: EntryFieldStyle,... {
...
let style: EntryFieldStyle
var body: some View {
AnyView (
style.makeBody(
.init(
value: value,
label: .init(underlyingView: AnyView(label)),
content: .init(underlyingView: AnyView(content)),
leadingAccessoryView: .init(underlyingView: AnyView(leadingAccessoryView)),
trailingAccessoryView:.init(underlyingView: AnyView(trailingAccessoryView))))
)
}
}
Update EntryFieldView
and ContentView
to pass the style:
EntryField(value: value,
content: TextField("Street Address", text: $value),
leadingAccessoryView: Image(systemName: "house"),
trailingAccessoryView: Image(systemName: "info.circle"),
style: DefaultEntryFieldStyle())
We have an style-able EntryField
now. We can add as many styles as we want. We will add 3 more styles in this tutorial.
Before that though, there are more urgent needs. We want to use the dot syntax, and we want to be able to set the style globally, like for the whole app.
To achieve all of this, we need to set entry field style as an environment value. Let’s do that.
# Create a file named EntryFieldEnvironmentValue
:
import SwiftUI
struct EntryFieldEnvironmentKey: EnvironmentKey {
static let defaultValue: any EntryFieldStyle = DefaultEntryFieldStyle()
}
extension EnvironmentValues {
var entryFieldStyle: any EntryFieldStyle {
get { self[EntryFieldEnvironmentKey.self] }
set { self[EntryFieldEnvironmentKey.self] = newValue }
}
}
public extension View {
func entryFieldStyle(_ style: some EntryFieldStyle) -> some View {
environment(\.entryFieldStyle, style)
}
}
# Now let’s use the environment value for the style variable. In EntryField
struct, replace:
let style: EntryFieldStyle
with:
@Environment(\.entryFieldStyle) var style
, we don’t need to define the Style generic type anymore so delete its declaration. Final result should be this
struct EntryField<Content: View, LeadingAccessoryView: View, TrailingAccessoryView: View>: View {
...
}
Since we added the environment value for the entryFieldType
, we have a default value now. So if you don’t pass a style, it will be the default:
EntryField(value: value,
content: TextField("Street Address", text: $value),
leadingAccessoryView: Image(systemName: "house"),
trailingAccessoryView: Image(systemName: "info.circle"))
Or you can set it like this:
EntryField(value: value,
content: TextField("Street Address", text: $value),
leadingAccessoryView: Image(systemName: "house"),
trailingAccessoryView: Image(systemName: "info.circle"))
.entryFieldStyle( DefaultEntryFieldStyle())
Last thing before we add other styles is adding the ability to use the dot syntax. We can achieve it by adding a static variable to each style in an extension.
# Create a file named EntryFieldStyleExtensions
and add this:
extension EntryFieldStyle where Self == DefaultEntryFieldStyle {
static var `default`: DefaultEntryFieldStyle { return .init() }
}
Finally, we can set it with the dot syntax.
EntryField(value: value,
content: TextField("Street Address", text: $value),
leadingAccessoryView: Image(systemName: "house"),
trailingAccessoryView: Image(systemName: "info.circle"))
.entryFieldStyle(.default)
# Now, let’s create RoundedEntryField
. We need 3 things:
- RoundedEntryField
- RoundedEntryFieldStyle
- Dot syntax extension
For RoundedEntryField
, we need one more variable, the label above the input area.
# Let’s add this to EntryField
and EntryFieldStyleConfiguration
, and update the call site which is the EntryFieldView
instance.
EntryField:
//Add label to EntryField
struct EntryField<Label: View /*...*/> {
@State var value: String
let label: Label //Add label here!!!
/...
var body: some View {
AnyView (
style.makeBody(
.init(
value: value,
label: .init(underlyingView: AnyView(label)), //and here!!!
content: .init(underlyingView: AnyView(content)),
leadingAccessoryView: .init(underlyingView: AnyView(leadingAccessoryView)),
trailingAccessoryView:.init(underlyingView: AnyView(trailingAccessoryView))))
)
}
}
EntryFieldView:
//Update EntryFieldView callsite
EntryField(value: value,
label: Text("Street Address"), //add label here!!!
content: TextField("Street Address", text: $value),
leadingAccessoryView: Image(systemName: "house"),
trailingAccessoryView: Image(systemName: "info.circle"))
EntryFieldStyleConfiguration:
public struct EntryFieldStyleConfiguration {
//...
let label: Label
struct Label: View {
var underlyingView: AnyView
var body: some View {
underlyingView
}
}
//...
}
# With the addition of label, we can create RoundedEntryField
, and RoundedEntryFieldStyle.
RoundedEntryField:
struct RoundedEntryField: View {
let configuration: EntryFieldStyleConfiguration
var body: some View {
VStack {
VStack (alignment: .leading, spacing: 4) {
configuration.label
.font(.caption)
.foregroundStyle(.secondary)
HStack(alignment: .center, spacing: 8) {
Spacer(minLength: 8)
configuration.leadingAccessoryView
.opacity(0.5)
configuration.content
configuration.trailingAccessoryView
.padding(.horizontal, 16)
.opacity(0.5)
}
.frame(minHeight: 54, alignment: .center)
.background {
Color.gray.opacity(0.2)
}
.cornerRadius(8)
}
Spacer()
}
.padding()
}
}
RoundedEntryFieldStyle:
struct RoundedEntryFieldStyle: EntryFieldStyle {
func makeBody(_ configuration: EntryFieldStyleConfiguration) -> some View {
RoundedEntryField(configuration: configuration)
}
}
EntryFieldStyleExtentions:
extension EntryFieldStyle where Self == RoundedEntryFieldStyle {
static var rounded: RoundedEntryFieldStyle { return .init() }
}
Now let’s add a preview and see how our RoundedEntryField
looks like. Put this into RoundedEntryField
file:
struct RoundedEntryFieldView: View {
@State var value: String = ""
var body: some View {
EntryField(value: value,
label: Text("Street Address"),
content: TextField("Street Address", text: $value),
leadingAccessoryView: Image(systemName: "house"),
trailingAccessoryView: Image(systemName: "info.circle"))
.entryFieldStyle(.rounded)
.onChange(of: value) { newValue in
print(newValue)
}
}
}
struct RoundedEntryField_Previews: PreviewProvider {
static var previews: some View {
RoundedEntryFieldView()
}
}
It looks like this:
This is it for the first part of this tutorial. We created a style-able EntryField
and added 2 styles so far.
In the upcoming parts, to finish our AddressForm
, we will add the following:
# 2 more entry fields styles
- FloatingEntryFieldStyle
- CapsuleEntryFieldStyle
# 5 entry field types
- NameField
- EmailField
- PhoneField
- StreetField
- CityField
- StateField
- ZipCodeField
# Autoformatting inputs
# Validation for each field and for the AddressForm
# Accessibility
# Localization
Follow me on twitter: https://twitter.com/_tahabebek
Watch this tutorial on youtube: https://www.youtube.com/watch?v=CakZ9z1qdgE&t=2496s