iosNudge

UI coding and take-homes

Chapter 7 of 10 5 min read

In this round you build something real. It is the round closest to your daily job. The interviewer wants to see if you can ship a clean, working feature, not just make it work.

There are two forms:

Live UI codingTake-home
Time60–90 minutes3–8 hours, over a few days
ToolXcode with screen sharing, sometimes a plain editorYour own Xcode
ScopeOne screen, maybe twoA small app
FocusSpeed, structure, talkingQuality, tests, README
AfterQuestions during the sessionA review call about your code

A typical task: "Load a list of items from this URL. Show them in a list. Tap an item to see details."

Step 1: Clarify the task

Ask before you build. The same questions work live and for a take-home (send them by email).

  • "SwiftUI or UIKit, or my choice?"
  • "Which iOS version should I support?"
  • "Can I use third-party libraries?" (Usually the answer is no, or "keep it small".)
  • "Do you want tests? Offline support? Pagination?"
  • "How much time should I spend?"

If you cannot ask, write your assumptions in the README.

Step 2: Plan the structure

Take 2 minutes to plan, even in a live round. Say it out loud.

"I will have a User model, a service protocol for networking, a view model that holds the screen state, and a SwiftUI view. The service is a protocol, so I can test the view model with a fake."

A small, clear folder structure:

UsersApp/
  App/
    UsersApp.swift
  Models/
    User.swift
  Services/
    UserService.swift
  Features/
    UserList/
      UserListView.swift
      UserListViewModel.swift
    UserDetail/
      UserDetailView.swift
UsersAppTests/
  UserListViewModelTests.swift
README.md

Do not over-build. Three layers (view, view model, service) are enough for most tasks.

Step 3: Model and networking

Start with the data. Make the model Decodable and the service a protocol.

struct User: Decodable, Identifiable, Equatable, Sendable {
    let id: Int
    let name: String
    let email: String
}

protocol UserService: Sendable {
    func fetchUsers() async throws -> [User]
}

struct LiveUserService: UserService {
    let url: URL

    func fetchUsers() async throws -> [User] {
        let (data, response) = try await URLSession.shared.data(from: url)
        guard let http = response as? HTTPURLResponse,
              (200..<300).contains(http.statusCode) else {
            throw URLError(.badServerResponse)
        }
        return try JSONDecoder().decode([User].self, from: data)
    }
}

Why reviewers like this:

  • The status code is checked. Many candidates skip it.
  • The protocol makes testing easy.
  • async/await keeps the code short and readable.

Step 4: View model with clear states

Every screen that loads data has at least four states: loading, loaded, empty and error. Use an enum. It makes impossible states impossible (for example "loading" and "error" at the same time).

@MainActor
@Observable
final class UserListViewModel {
    enum LoadState: Equatable {
        case loading
        case loaded([User])
        case empty
        case failed(String)
    }

    private(set) var state: LoadState = .loading
    private let service: UserService

    init(service: UserService) {
        self.service = service
    }

    func load() async {
        state = .loading
        do {
            let users = try await service.fetchUsers()
            state = users.isEmpty ? .empty : .loaded(users)
        } catch {
            state = .failed("Could not load users. Please try again.")
        }
    }
}

@MainActor means all state changes happen on the main thread. So the UI is always updated safely.

Step 5: The view

The view only reads the state and sends actions. No networking or parsing in the view.

import SwiftUI

struct UserListView: View {
    @State private var viewModel: UserListViewModel

    init(viewModel: UserListViewModel) {
        _viewModel = State(initialValue: viewModel)
    }

    var body: some View {
        content
            .navigationTitle("Users")
            .task { await viewModel.load() }
    }

    @ViewBuilder
    private var content: some View {
        switch viewModel.state {
        case .loading:
            ProgressView("Loading…")
        case .loaded(let users):
            List(users) { user in
                NavigationLink(value: user.id) {
                    VStack(alignment: .leading) {
                        Text(user.name).font(.headline)
                        Text(user.email)
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                }
            }
            .refreshable { await viewModel.load() }
        case .empty:
            ContentUnavailableView("No users yet", systemImage: "person.2")
        case .failed(let message):
            ContentUnavailableView {
                Label("Something went wrong", systemImage: "wifi.exclamationmark")
            } description: {
                Text(message)
            } actions: {
                Button("Try again") {
                    Task { await viewModel.load() }
                }
            }
        }
    }
}

Small details that score points:

  • Pull to refresh with .refreshable.
  • A "Try again" button on error.
  • System fonts like .headline, so Dynamic Type works.
  • .task instead of .onAppear, so the work is cancelled when the view goes away.

Step 6: Tests

Test the view model, not the view. Use a fake service.

import Testing
@testable import UsersApp

struct FakeUserService: UserService {
    let result: Result<[User], any Error>

    func fetchUsers() async throws -> [User] {
        try result.get()
    }
}

@MainActor
struct UserListViewModelTests {
    @Test func showsUsersAfterLoading() async {
        let users = [User(id: 1, name: "Ana", email: "ana@example.com")]
        let viewModel = UserListViewModel(service: FakeUserService(result: .success(users)))

        await viewModel.load()

        #expect(viewModel.state == .loaded(users))
    }

    @Test func showsEmptyStateForEmptyList() async {
        let viewModel = UserListViewModel(service: FakeUserService(result: .success([])))

        await viewModel.load()

        #expect(viewModel.state == .empty)
    }

    @Test func showsErrorWhenRequestFails() async {
        let viewModel = UserListViewModel(
            service: FakeUserService(result: .failure(URLError(.notConnectedToInternet)))
        )

        await viewModel.load()

        guard case .failed = viewModel.state else {
            Issue.record("Expected the failed state, got \(viewModel.state)")
            return
        }
    }
}

Three tests that cover the main states are better than ten tests of trivial getters. If the team uses XCTest, the same tests work with XCTestCase and XCTAssertEqual.

In a live round with little time, say: "I would add tests for the loading, empty and error states." Then write at least one if you can.

Step 7: The README (take-homes)

The README is often the first thing a reviewer reads. A good one saves them time and explains your choices.

# Users App

## How to run
Open UsersApp.xcodeproj in Xcode 26 or later. Run the UsersApp scheme. No setup needed.

## What it does
- Loads users from the API and shows them in a list.
- Tap a user to see details.
- Pull to refresh. Loading, empty and error states.

## Architecture
MVVM. Views are SwiftUI. View models are @MainActor @Observable classes.
Networking is behind a UserService protocol, so view models are easy to test.

## Decisions and trade-offs
- No third-party libraries, to keep the project small.
- No disk cache. With more time I would cache the last response for offline use.

## Tests
UserListViewModelTests covers the loaded, empty and error states.

## With more time I would
- Add pagination.
- Add snapshot tests for the main screens.
- Improve VoiceOver labels on the detail screen.

## Time spent
About 5 hours.

The "With more time" section is important. It shows you know what is missing, and that you chose to stop on purpose.

What reviewers check

AreaWhat they look for
It worksBuilds with no errors or warnings. No crashes. Follows the task.
StructureClear layers. Views do not do networking. Small files.
StatesLoading, empty, error, and success are all handled.
Swift styleClear names, no force unwraps, private where it should be.
ConcurrencyUI updates on the main actor. No data races. Tasks cancelled when needed.
TestsThe important logic is tested. Tests are readable.
UX detailsPull to refresh, retry, Dynamic Type, dark mode, VoiceOver labels.
Git historySmall commits with clear messages, if they can see it.
READMEHow to run, decisions, trade-offs, what is missing.

Live round tips

  • Get something on screen fast. A working list with fake data in 15 minutes is a good start. Then add networking.
  • Talk while you build. "Now I add the error state. I use an enum so the states cannot mix."
  • Keep a short TODO list in a comment. It shows you have a plan.
  • Do not fight Xcode. If the preview breaks, run in the simulator and move on.
  • Leave 10 minutes for questions about your code and what you would improve.
// TODO (live round plan):
// 1. Model + fake data in the list   <- done
// 2. Real networking
// 3. Error and empty states
// 4. Detail screen
// 5. One view model test

Take-home tips

  • Respect the time limit. If they say 4 hours, do not spend 20. Write the real time in the README.
  • Quality over features. A small app with tests and states beats a big app with bugs.
  • Clean up before sending. Remove commented-out code, print statements and unused files.
  • Run it fresh. Clone your own repo into a new folder and build it. Make sure it works.
  • Prepare for the review call. Be ready to explain every choice and to change the code live: "How would you add search?"

Common follow-up questions

  • "How would you add pagination?" — Keep the current page in the view model, load the next page when the last cell appears, and stop when the server returns no more items.
  • "How would you cache images?" — In-memory cache (for example NSCache) plus a disk cache, keyed by URL, behind a protocol.
  • "How would you support offline?" — Save the last response to disk or SwiftData, show it first, then refresh from the network.
  • "Why MVVM here?" — It moves logic out of the view, so it is easy to test. For a screen this small, it is enough without extra layers.
  • "What would you change in UIKit?" — A UITableView with a diffable data source, and the same view model.

Practise on this site

The UI build questions on this site have a list of requirements, a starter file and a full solution. Build each one in Xcode with a timer. Then compare your code with the solution and the checklist above.