SwiftUI Tutorial -Creating a Style-able Address Form Component — Part 1

Taha Bebek
7 min readDec 4, 2022

--

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

--

--