iosNudge

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

  1. The user types anna@ and moves to the password field. The email field shows "Enter a valid email, like name@example.com."
  2. The user fixes the email. The error goes away while they type.
  3. The user types an 8+ character password. The button becomes enabled.
  4. The user taps "Log in". A spinner shows and the fields are locked.
  5. The password is wrong. The screen shows "Wrong email or password."
  6. The user edits the password. The red message goes away.

Requirements

  • Email is valid when it looks like name@domain.tld with 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 textContentType so 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, @FocusState and async/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.