In the last part of this SwiftUI course, you've learned all about how to create and arrange SwiftUI views to craft beautiful interfaces. In this part, you'll put those skills to test by building out a login screen for your app.
You'll build a login screen with an email field, some information and a button at the bottom. Besides just building the screen, we'll also add a way to navigate from the welcome screen you created in the last part to the new login screen.
Ready to get started?
You can find the finished project code on GitHub.
Kicking things off
To create the login screen, create a new SwiftUI View file called LoginView.swift. For now, change the body to display a text saying "Log In":
struct LoginView: View {
var body: some View {
Text("Log In")
}
}
You'll fill up this placeholder screen later, but you'll first need a way to navigate to the screen.
I am creating this course in collaboration with CometChat, a modern chat platform to help you add chat to you Swift app. During the next few weeks, we’ll be releasing installments of our free SwiftUI course here on dev.to! In the course, you’ll dive deep into SwiftUI by building a real-world production-scale chat app, learning SwiftUI in a practical way, on a scale larger than a simple example app. Follow me to get notified of future parts of this course! You can also follow @CometChat on Twitter see the course of CometChat’s blog.
Navigation in SwiftUI
Two SwiftUI views will help you with navigation: NavigationView
and NavigationLink
. NavigationView
is SwiftUI's version of UINavigationController
: It manages a stack of views and shows a navigation bar on top of them.
NavigationLink
is similar to a Button
, but it triggers presenting a new view in the navigation stack when it's pressed. To work correctly, a NavigationLink
needs to be nested inside a NavigationView
. In UIKit terms, if NavigationView
is the navigation controller, NavigationLink
is a segue.
Adding a SwiftUI Navigation View and a navigation bar
In iOS apps, you'd often have one navigation controller that will be the initial view controller of your app. You can achieve a similar effect in SwiftUI by making the initial view a NavigationView
.
The initial view is determined by the SceneDelegate
. If you look inside SceneDelegate.swift you'll find an implementation of scene(_:willConnectTo:, options:)
that creates a content view and adds it to the window.
Instead of creating a plain WelcomeView
, change contentView
so that you wrap the welcome screen in a navigation view:
let contentView = NavigationView { WelcomeView() }
contentView
, just like any other SwiftUI view, can be arbitrarily complex. The scene delegate is a good place to add global configuration, like changing colors, adding routing, subscribing to global events and other changes that affect the whole app.
Run the project now and you'll notice a giant space on the top of your welcome screen.
This space is taken up by an empty navigation bar. Let's look at how to change the look and content of this bar.
Styling the navigation bar in SwiftUI
Let's fill up the navigation bar with a title on the welcome screen. Open WelcomeView.swift, and add a navigation bar title to the top-most VStack
:
var body: some View {
VStack(alignment: .leading) {
...
}
.navigationBarTitle("Create an account")
}
If you run the project, you might notice there's now two texts saying "Create an account".
Remove the one inside the VStack
:
var body: some View {
VStack(alignment: .leading) {
/* Remove from here...
Text("Create an account")
.modifier(BodyText())
.padding()
... to here*/
Text("Connect with people around the world")
.modifier(TitleText())
.padding([.bottom, .leading, .trailing])
...
}
The view looks the same as it did before, but the text is now part of the navigation bar.
Note: If you're wondering how to get the default-looking iOS navigation bar, you can use
navigationBarTitle(_:displayMode:)
and pass in.inline
as the display mode.
Since your view is wrapped inside a NavigationView
, you can now use NavigationLink
to present the login screen. Wrap the primary button in body
inside a NavigationLink
(instead of a Button
):
var body: some View {
VStack(alignment: .leading) {
...
VStack(spacing: 30) {
// Change this line:
NavigationLink(destination: LoginView()) {
PrimaryButton(title: "Log In")
}
Button(action: { }) {
SecondaryButton(title: "Sign Up")
}
}
...
}
}
Just like Button
, NavigationLink
will wrap around a view and make it interactable. Instead of calling a function, though, the navigation link will present the provided view when it's tapped.
If you run the project now and tap the log in button, you'll be taken to your login view.
The view inside the navigation link can be anything you want, but be aware that views that respond to touches, like buttons, will consume the touch and it won't propagate to the navigation link. In other words, buttons inside navigation links won't trigger the presentation. Watch out for those hungry buttons, lest they eat your touches!
Making a reusable SwiftUI text field
Now that you can navigate to the login view, you'll start building out that view.
Since you're building a login form, you'll first build a reusable text field that you can use throughout your app. Small reusable views are a leitmotif of SwiftUI apps! Once you're done, it will look like this:
Already you can see the views you'll combine to create this text field. In a vertical stack, you'll need Text
for the title, a TextField
to edit the text, an Image
to show the icon and a way to display the line on the bottom of the view.
Before you start working on the view, let's first add the little email icon. You can find the image here. Drag it over to your Assets.xcassets file and name it email.
Next, create a new SwiftUI View file called ErrorTextField.swift. It's called error text field because you'll add the ability to show an error if the text is invalid. Change the struct to the following:
struct ErrorTextField: View {
let title: String
let placeholder: String
let iconName: String
let text: Binding<String>
let keyboardType: UIKeyboardType
let isValid: (String) -> Bool
init(title: String,
placeholder: String,
iconName: String,
text: Binding<String>,
keyboardType: UIKeyboardType = UIKeyboardType.default,
isValid: @escaping (String)-> Bool = { _ in true}) {
self.title = title
self.placeholder = placeholder
self.iconName = iconName
self.text = text
self.keyboardType = keyboardType
self.isValid = isValid
}
}
It looks like a lot of code but don't worry — it's a boilerplate initializer that sets up all the necessary properties of the view. You'll need a title and a placeholder, the image name of the icon, a function to validate the text and the keyboard type of the text field.
You'll also need a binding to the text. A binding is similar to a state variable, but it's used to bind data between two different views. For instance, you can provide the text field with a binding to your text. The text field will change the text, and your view will be re-drawn.
Think of binding as a state variable that you can pass to other views. It's kind of like giving someone your phone number and telling them to call you when something changes.
In this case, you'll receive a binding to the text that you'll pass along to the TextField
view.
Since this view can show an error, add a computed property that will determine whether an error should be shown:
var showsError: Bool {
if text.wrappedValue.isEmpty {
return false
} else {
return !isValid(text.wrappedValue)
}
}
There's no point in showing the error if the text is empty. If the text is not empty, you'll use the provided text validation function to determine if an error should get shown.
Next, create the body
for this view by adding the title to a stack:
var body: some View {
VStack(alignment: .leading) {
Text(title)
.foregroundColor(Color(.lightGray))
.fontWeight(.bold)
}
}
To make your life easier, you'll also modify the preview to show the different possible states of the text field all at once:
struct ErrorTextField_Previews: PreviewProvider {
static var previews: some View {
Group {
ErrorTextField(
title: "Email",
placeholder: "test@email.com",
iconName: "email",
text: .constant(""))
.padding()
.previewLayout(.fixed(width: 400, height: 100))
ErrorTextField(
title: "Email",
placeholder: "test@email.com",
iconName: "email",
text: .constant("some@email.com"))
.padding()
.previewLayout(.fixed(width: 400, height: 100))
ErrorTextField(
title: "Email",
placeholder: "test@email.com",
iconName: "email",
text: .constant("someemail.com"),
isValid: { _ in false })
.padding()
.previewLayout(.fixed(width: 400, height: 100))
}
}
}
Note: You'll notice the text is
.constant("some value")
. Theconstant
is a factory static function that creates a binding that never changes. It's useful for testing and previews, like in the above example.
Just like in the previous part of this SwiftUI course, you create multiple previews by adding views into a Group
.
Now that you can see what you're doing, let's add the text field and the email icon.
Laying out SwiftUI views horizontally
With that out of the way, you can continue building the view by adding the text field and the icon. To make sure the text field and the icon are next to each other, you can use an HStack
:
var body: some View {
VStack(alignment: .leading) {
Text(title)
.foregroundColor(Color(.lightGray))
.fontWeight(.bold)
// New code:
HStack {
TextField(placeholder, text: text)
.keyboardType(keyboardType)
.autocapitalization(.none)
Image(iconName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 18, height: 18)
}
}
}
You're already familiar with VStack
. Well, HStack
works the same, except horizontally. You arrange a text field and an image in a horizontal stack.
The TextField
is the SwiftUI equivalent of UIKit's UITextField
, except it's plain by default — which is exactly what we need.
Note: If you want to style a text field to look like a regular UIKit text field, you can call
textFieldStyle(RoundedBorderTextFieldStyle())
on the text field. The rounded border style is the one used by the good oldUITextField
.
In the previous part of this SwiftUI course, you learned about making image views expand to match their parent. In this case, you want the image to have a fixed size. That's why you call frame
and pass it an exact width and height.
Displaying basic shape views in SwiftUI
Finally, you'll add the border on the bottom of the text field. Since the border is just a plain rectangular view with no content, you can use a Rectangle
:
var body: some View {
VStack(alignment: .leading) {
Text(title)
.foregroundColor(Color(.lightGray))
.fontWeight(.bold)
HStack { ... }
// New code:
Rectangle()
.frame(height: 2)
.foregroundColor(showsError ?
.red :
Color(red: 189 / 255, green: 204 / 255, blue: 215 / 255))
}
}
As its name suggests, Rectangle
is just, well, a rectangle. Perfect for displaying borders or backgrounds. You set its height to 2 and leave other dimensions up to SwiftUI. You'll also set its color to red if showsError
is true, otherwise, you'll use a light gray color.
Rectangle
is only one of a few basic shape views. There's also RoundedRectangle
, Circle
, Capsule
and Ellipse
. Remember these when you need to draw shapes — there's no need to resort to CAShapeLayer
anymore.
You now have a nice looking text field that is flexible enough to be used throughout your app. It's time to put it to action in the login screen!
Creating a SwiftUI login screen
Now, finally, you can get to work on the login screen! Head back to LoginView.swift and change body
to the following:
var body: some View {
VStack(alignment: .leading, spacing: 26) {
Text("Log In")
.modifier(TitleText())
}
.padding()
}
First, you'll make sure stack view items are aligned to the left edge, just like in the last part of this SwiftUI course. Add a spacing of 26 points between each item and make sure the "Log In" text is styled as a title. You'll also add the system padding to the stack.
Remember that the text field you built receives a validation function? Let's create one that will validate an email. Add the following method to the struct:
private func isValid(email: String) -> Bool {
let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
let predicate = NSPredicate(format:"SELF MATCHES %@", regex)
return predicate.evaluate(with: email)
}
This method checks the email string against a regular expression. If the email doesn't contain an @ sign, a dot, and text around both of those, this function will return false
.
Also, add a state variable for the entered text:
@State private var email = ""
With those two pieces in place, you can now add a text field for the email to your view:
var body: some View {
VStack(alignment: .leading, spacing: 26) {
Text("Log In")
.modifier(TitleText())
// New code:
ErrorTextField(
title: "Email",
placeholder: "mail@example.com",
iconName: "email",
text: $email,
keyboardType: .emailAddress,
isValid: isValid)
}
.padding()
}
Most of the properties should be self-explanatory, except maybe this weird $text
thing. Don't worry, you're not programming PHP! $
is a special character that converts an @State
variable to a binding. Remember, bindings are state variables that can be changed from a different view. In this case, LoginView
will pass the email
as a binding to ErrorTextField
.
Next, create an empty function that you'll call when the user presses the login button:
private func login() {
// Initiate network request
}
Then, add a spacer and a button that calls this function. As you learned in the previous part of this SwiftUI course, the spacer will expand to make sure everything above it is on top, while the button is on the very bottom of the stack. Add the following to body
:
var body: some View {
VStack(alignment: .leading, spacing: 26) {
...
Spacer()
Button(action: login) {
PrimaryButton(title: "Log In")
}
}
.padding()
}
Whenever the button is pressed, SwiftUI will call your login
function which, currently, does absolutely nothing.
Don't be disappointed, you'll fix this soon.
Presenting a SwiftUI view asynchronously
Later in this course, login
will perform a network request to log the user in and then present a contacts screen. For now, though, you'll navigate to an empty screen when the button is pressed.
Earlier, you learned how to use NavigationLink
to present a new view in the navigation stack when a button is pressed. Often, though, you don't want to immidiately go to a new screen. You'll usually want to perform a check, a network request or some other bit of logic, and then present a new screen programmatically when you're done. SwiftUI has a way to do that, but it is a bit clumsy.
First, create a new state variable:
@State private var showContacts = false
You'll navigate to the contacts screen when this variable gets set to true
. To do this, you can use a NavigationLink
, but differently from before. Instead of wrapping a button in the navigation link, you'll show a hidden link that the user won't see.
You'll also use a different NavigationLink
initializer: NavigationLink(destination:isActive:label)
. The key here is isActive
: This is a binding to a bool variable. When it gets set from false
to true
NavigationLink
will know to trigger the presentation.
Add the navigation link to the bottom of body
:
var body: some View {
VStack(alignment: .leading, spacing: 26) {
...
Button(action: login) {
PrimaryButton(title: "Log In")
}
NavigationLink(destination: EmptyView(), isActive: $showContacts) {
EmptyView()
}
}
.padding()
}
By making navigation link's body an EmptyView
you make sure that the user can't see the link. You set its isActive
binding to the state variable you created earlier.
Finally, set showContacts
to true
at the bottom of login
:
showContacts = true
When the user presses the login button, SwiftUI calls login
, which sets showContacts
to true, triggering the NavigationLink
, which then presents an empty view in the navigation stack. This Rube Goldberg machine of events is what happens when you try to do an imperative thing, like presenting a view programmatically, in a declarative UI framework.
Presenting multiple SwiftUI views asynchronously
To expand this NavigationLink
pattern to multiple views, you'd have to have one state variable for each view you want to present, leading to multiple flags in your view that can be false and true at the same time. This doesn't scale well.
To solve this problem, NavigationLink
offers a third initializer: NavigationLink(_:destination:tag:selection:)
. While the one you used previously has a binding to a Boolean, this initializer is generic. selection
is a binding to any Hashable
type, like integers, strings and even enums. If the current value of selection
matches the value of tag
, the link will get triggered.
For instance, let's say you wanted to present either a login or a registration screen based on some logic. You'd start by defining an enum with cases for each of the views you'd like to present.
enum PresentedView {
case login
case registration
}
You'll also need a state variable to track which view should be shown:
@State private var viewToPresent: PresentedView?
Then, inside body
, you can create hidden navigation links to each of your views.
NavigationLink(
destination: LoginView(),
tag: .login,
selection: $viewToPresent) {
EmptyView()
}
NavigationLink(
destination: RegistrationView(),
tag: .registration,
selection: $viewToPresent) {
EmptyView()
}
When you want to present one of these two views, set viewToPresent
to the corresponding value:
viewToPresent = .login
The navigation link will check the bound value, and if it matches its tag, present the view. This solution is easier to scale, so if you have more than one view you'd like to present, I suggest you use this approach.
Conclusion
And there you have it! You've built out a login screen and by doing so you've learned a bunch of important SwiftUI concepts:
- How to present and style SwiftUI text fields, as well as how to use basic SwiftUI shape views.
- How to use a
NavigationView
to show and style a navigation bar. - How to push a view when you press a button using
NavigationLink
. - How to push SwiftUI views programmatically using
NavigationLink(destination:isActive)
orNavigationLink(destination:tag:selection:)
.
I'd say that's a good day's work! No need to stop now, though.
In the next part, you'll learn all about SwiftUI lists by building out a contacts screen to show your user's friends. You'll also begin your journey into making network requests!
Top comments (2)
Writing this for others who may run into the same situation. I was getting a consistent error: "Type 'ErrorTextField' does not conform to protocol 'View'" on the ErrorTextField struct so I tried reloading xcode and even downloaded the completed project files from github to compare the swift file. They were the same but my code caused the error which prevented a successful build. Very frustrating.
The fix turned out to be simply copy/pasting the entire code from the completed project file over my own. When I reverted back to the previous code which had caused the error (which still wasn't complete as I didn't get any further than adding the preview code) it stopped throwing the error even though nothing had changed.
Lesson learned, don't trust the error messages in SwiftUI yet. Many are not that helpful and some of them are misleading or completely wrong!
Hi, there is a mistake
Where it say
let contentView = NavigationView { WelcomeView() }
Must it say
let contentView: some View = NavigationView {
WelcomeView()
}
I found this in the code
I am learning swift, I love this course!!
Sorry for my english.
Juan Manuel