Login Form
MediumSwiftUISwiftUIConcurrencyAccessibility 40 min read
Build a login screen in SwiftUI. It has an email field, a password field and a "Log in" button.
Use an @Observable view model. A fake service is in starter.swift.
protocol AuthService: Sendable {
func logIn(email: String, password: String) async throws -> User
}
// FakeAuthService waits 1 second.
// "password123" works. Every other password throws LoginError.invalidCredentials.Example flow
- The user types
anna@and moves to the password field. The email field shows "Enter a valid email, like name@example.com." - The user fixes the email. The error goes away while they type.
- The user types an 8+ character password. The button becomes enabled.
- The user taps "Log in". A spinner shows and the fields are locked.
- The password is wrong. The screen shows "Wrong email or password."
- The user edits the password. The red message goes away.
Requirements
- Email is valid when it looks like
name@domain.tldwith no spaces. Trim spaces at the ends. - Password is valid when it has at least 8 characters.
- Validate live: errors appear and disappear as the user types.
- Do not show an error before the user has left the field once. Nobody likes red text after typing one letter.
- The button is disabled while the form is invalid or loading.
- While loading: show a spinner in the button and lock the fields.
- Show the server error under the button. Clear it when the user edits a field.
- After success, show a simple welcome screen with a "Log out" button.
Keyboard and focus
- Email field: email keyboard, no auto-capitalization, no autocorrect.
- Return on the email field moves the cursor to the password field.
- Return on the password field ("Go") submits the form.
- If the user submits an invalid form, move the cursor to the first bad field.
- Hide the keyboard when the request starts.
What a good answer handles
- Double tap. Two quick taps must not send two requests.
- Password AutoFill. Use the right
textContentTypeso iCloud Keychain can fill the form. - VoiceOver. Red text is not enough. Read the server error out loud.
- Testability. The view model gets the service through its
init, so tests can pass a fake.
Notes
- Target iOS 17 or later. Use
@Observable,@FocusStateandasync/await. - No real network. Use
FakeAuthService.
Starter code
Copy this into a new Xcode project (or Swift Playgrounds) and build from here.
import SwiftUI
// MARK: - Service (done for you)
struct User: Sendable {
let email: String
}
enum LoginError: LocalizedError {
case invalidCredentials
var errorDescription: String? {
switch self {
case .invalidCredentials: "Wrong email or password."
}
}
}
protocol AuthService: Sendable {
func logIn(email: String, password: String) async throws -> User
}
/// Fake backend. It waits 1 second.
/// The password "password123" works. Every other password fails.
struct FakeAuthService: AuthService {
func logIn(email: String, password: String) async throws -> User {
try await Task.sleep(for: .seconds(1))
guard password == "password123" else { throw LoginError.invalidCredentials }
return User(email: email)
}
}
// MARK: - View model
@MainActor
@Observable
final class LoginViewModel {
var email = ""
var password = ""
private(set) var isLoading = false
private(set) var errorMessage: String?
private(set) var user: User?
private let service: any AuthService
init(service: any AuthService = FakeAuthService()) {
self.service = service
}
var isEmailValid: Bool {
// TODO: something@something.something, no spaces
false
}
var isPasswordValid: Bool {
// TODO: at least 8 characters
false
}
var canSubmit: Bool {
// TODO
false
}
func logIn() async {
// TODO: set isLoading, call the service, set `user` or `errorMessage`.
}
}
// MARK: - View
struct LoginView: View {
@State private var model = LoginViewModel()
var body: some View {
NavigationStack {
Form {
TextField("Email", text: $model.email)
SecureField("Password", text: $model.password)
Button("Log in") {
Task { await model.logIn() }
}
// TODO: field errors, disabled state, loading state, server error,
// keyboard focus (@FocusState), welcome screen after login.
}
.navigationTitle("Log in")
}
}
}
#Preview {
LoginView()
}Solution
Try to build it first. Then compare your design with ours.