Lab 4.1 — News reader

Goal: Build a UIKit news reader app that fetches headlines from a public API, displays them in a list with self-sizing cells, supports pull-to-refresh, shows loading/error/empty states, and pushes a detail view on tap.

Time: ~90 minutes Phase prerequisites: Chapters 4.1 – 4.5, 4.8

What you’ll build

A two-screen UIKit app:

  • List screenUITableView (or UICollectionView with list layout) showing article headlines, source, and timestamp. Pull-to-refresh, error banner with retry, loading shimmer.
  • Detail screen — Pushed when a row is tapped. Shows full article info with a “Open in Safari” button.

Stack: UIKit, programmatic Auto Layout, diffable data source, URLSession async/await, Codable.

Setup

  1. New Xcode project: App template, name NewsReader, language Swift, interface Storyboard, lifecycle UIKit App Delegate.
  2. Delete Main.storyboard. In project settings → Targets → Info → “Main storyboard file base name” — delete the value. In Info → Application Scene Manifest, delete “Storyboard Name” entries.
  3. Set up SceneDelegate.scene(_:willConnectTo:options:) to make the window programmatically.

Step 1 — Configure the window

// SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
    guard let windowScene = scene as? UIWindowScene else { return }
    let window = UIWindow(windowScene: windowScene)
    window.rootViewController = UINavigationController(rootViewController: ArticleListVC())
    window.makeKeyAndVisible()
    self.window = window
}

Step 2 — Pick an API

Two free options (no signup required):

  • Hacker News Firebase API: https://hacker-news.firebaseio.com/v0/topstories.json then per-item https://hacker-news.firebaseio.com/v0/item/<id>.json
  • Spaceflight News API: https://api.spaceflightnewsapi.net/v4/articles/?limit=30 — returns a single JSON page with all needed fields

Use Spaceflight News API — simpler, single request, includes title/summary/url/imageUrl/publishedAt.

Step 3 — Model

// Models/Article.swift
struct ArticleResponse: Decodable {
    let results: [Article]
}

struct Article: Decodable, Hashable, Identifiable {
    let id: Int
    let title: String
    let url: String
    let imageUrl: String?
    let newsSite: String
    let summary: String
    let publishedAt: Date

    enum CodingKeys: String, CodingKey {
        case id, title, url
        case imageUrl = "image_url"
        case newsSite = "news_site"
        case summary
        case publishedAt = "published_at"
    }
}

Step 4 — Networking

// Networking/NewsAPI.swift
enum NewsAPIError: Error {
    case badURL, badResponse, decoding(Error), transport(Error)
}

final class NewsAPI {
    private let session: URLSession

    init(session: URLSession = .shared) { self.session = session }

    func fetchArticles() async throws -> [Article] {
        guard let url = URL(string: "https://api.spaceflightnewsapi.net/v4/articles/?limit=30") else {
            throw NewsAPIError.badURL
        }
        do {
            let (data, response) = try await session.data(from: url)
            guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
                throw NewsAPIError.badResponse
            }
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            do {
                let envelope = try decoder.decode(ArticleResponse.self, from: data)
                return envelope.results
            } catch {
                throw NewsAPIError.decoding(error)
            }
        } catch let error as NewsAPIError {
            throw error
        } catch {
            throw NewsAPIError.transport(error)
        }
    }
}

Step 5 — View state

// ViewModels/ArticleListState.swift
enum ArticleListState {
    case idle
    case loading
    case loaded([Article])
    case empty
    case error(String)
}

Step 6 — Cell

// Views/ArticleCell.swift
import UIKit

final class ArticleCell: UITableViewCell {
    static let reuseID = "ArticleCell"

    private let titleLabel: UILabel = {
        let l = UILabel()
        l.font = .preferredFont(forTextStyle: .headline)
        l.numberOfLines = 0
        l.adjustsFontForContentSizeCategory = true
        l.translatesAutoresizingMaskIntoConstraints = false
        return l
    }()

    private let sourceLabel: UILabel = {
        let l = UILabel()
        l.font = .preferredFont(forTextStyle: .caption1)
        l.textColor = .secondaryLabel
        l.adjustsFontForContentSizeCategory = true
        l.translatesAutoresizingMaskIntoConstraints = false
        return l
    }()

    private let summaryLabel: UILabel = {
        let l = UILabel()
        l.font = .preferredFont(forTextStyle: .subheadline)
        l.textColor = .label
        l.numberOfLines = 3
        l.adjustsFontForContentSizeCategory = true
        l.translatesAutoresizingMaskIntoConstraints = false
        return l
    }()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        let stack = UIStackView(arrangedSubviews: [titleLabel, summaryLabel, sourceLabel])
        stack.axis = .vertical
        stack.spacing = 6
        stack.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(stack)
        NSLayoutConstraint.activate([
            stack.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
            stack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
            stack.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
            stack.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
        ])
        accessoryType = .disclosureIndicator
    }

    required init?(coder: NSCoder) { fatalError() }

    func configure(with article: Article) {
        titleLabel.text = article.title
        summaryLabel.text = article.summary
        let formatter = RelativeDateTimeFormatter()
        formatter.unitsStyle = .short
        let when = formatter.localizedString(for: article.publishedAt, relativeTo: .now)
        sourceLabel.text = "\(article.newsSite) · \(when)"
    }
}

Step 7 — List view controller

// VCs/ArticleListVC.swift
import UIKit

final class ArticleListVC: UIViewController {

    private enum Section { case main }

    private let api = NewsAPI()
    private var tableView: UITableView!
    private var dataSource: UITableViewDiffableDataSource<Section, Article>!
    private var loadTask: Task<Void, Never>?
    private let refreshControl = UIRefreshControl()
    private let statusLabel: UILabel = {
        let l = UILabel()
        l.textAlignment = .center
        l.numberOfLines = 0
        l.font = .preferredFont(forTextStyle: .body)
        l.textColor = .secondaryLabel
        l.translatesAutoresizingMaskIntoConstraints = false
        return l
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Spaceflight"
        view.backgroundColor = .systemBackground
        setupTableView()
        setupStatusLabel()
        configureDataSource()
        Task { await load(showsSpinner: true) }
    }

    private func setupTableView() {
        tableView = UITableView(frame: view.bounds, style: .plain)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.register(ArticleCell.self, forCellReuseIdentifier: ArticleCell.reuseID)
        tableView.rowHeight = UITableView.automaticDimension
        tableView.estimatedRowHeight = 100
        tableView.delegate = self
        tableView.refreshControl = refreshControl
        refreshControl.addTarget(self, action: #selector(pulledToRefresh), for: .valueChanged)
        view.addSubview(tableView)
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
    }

    private func setupStatusLabel() {
        view.addSubview(statusLabel)
        NSLayoutConstraint.activate([
            statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            statusLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
            statusLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
        ])
        statusLabel.isHidden = true
    }

    private func configureDataSource() {
        dataSource = UITableViewDiffableDataSource<Section, Article>(tableView: tableView) { tv, indexPath, article in
            let cell = tv.dequeueReusableCell(withIdentifier: ArticleCell.reuseID, for: indexPath) as! ArticleCell
            cell.configure(with: article)
            return cell
        }
    }

    @objc private func pulledToRefresh() {
        Task { await load(showsSpinner: false) }
    }

    private func load(showsSpinner: Bool) async {
        loadTask?.cancel()
        if showsSpinner { showStatus("Loading…") }
        loadTask = Task { [weak self] in
            guard let self else { return }
            do {
                let articles = try await api.fetchArticles()
                try Task.checkCancellation()
                await MainActor.run {
                    self.refreshControl.endRefreshing()
                    if articles.isEmpty {
                        self.showStatus("No articles right now.")
                    } else {
                        self.hideStatus()
                        var snap = NSDiffableDataSourceSnapshot<Section, Article>()
                        snap.appendSections([.main])
                        snap.appendItems(articles)
                        self.dataSource.apply(snap, animatingDifferences: true)
                    }
                }
            } catch is CancellationError {
                return
            } catch {
                await MainActor.run {
                    self.refreshControl.endRefreshing()
                    self.showStatus("Couldn't load articles.\n\(error.localizedDescription)\n\nPull to retry.")
                }
            }
        }
        await loadTask?.value
    }

    private func showStatus(_ message: String) {
        statusLabel.text = message
        statusLabel.isHidden = false
        tableView.isHidden = true
    }

    private func hideStatus() {
        statusLabel.isHidden = true
        tableView.isHidden = false
    }
}

extension ArticleListVC: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        guard let article = dataSource.itemIdentifier(for: indexPath) else { return }
        navigationController?.pushViewController(ArticleDetailVC(article: article), animated: true)
    }
}

Step 8 — Detail view controller

// VCs/ArticleDetailVC.swift
import UIKit
import SafariServices

final class ArticleDetailVC: UIViewController {
    private let article: Article

    init(article: Article) {
        self.article = article
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        title = article.newsSite

        let titleLabel = UILabel()
        titleLabel.text = article.title
        titleLabel.font = .preferredFont(forTextStyle: .title1)
        titleLabel.numberOfLines = 0

        let summaryLabel = UILabel()
        summaryLabel.text = article.summary
        summaryLabel.font = .preferredFont(forTextStyle: .body)
        summaryLabel.numberOfLines = 0

        let openButton = UIButton(configuration: .filled(), primaryAction: UIAction(title: "Open in Safari") { [weak self] _ in
            guard let self, let url = URL(string: article.url) else { return }
            present(SFSafariViewController(url: url), animated: true)
        })

        let stack = UIStackView(arrangedSubviews: [titleLabel, summaryLabel, openButton])
        stack.axis = .vertical
        stack.spacing = 16
        stack.alignment = .leading
        stack.translatesAutoresizingMaskIntoConstraints = false

        let scroll = UIScrollView()
        scroll.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(scroll)
        scroll.addSubview(stack)

        NSLayoutConstraint.activate([
            scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            scroll.bottomAnchor.constraint(equalTo: view.bottomAnchor),

            stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 16),
            stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -16),
            stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 16),
            stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -16),
            stack.widthAnchor.constraint(equalTo: scroll.frameLayoutGuide.widthAnchor, constant: -32),
        ])
    }
}

Step 9 — Run

Build and run. You should see:

  • “Loading…” briefly
  • A list of articles
  • Pull down to refresh — spinner appears, list updates
  • Tap a row — pushes the detail screen
  • Tap “Open in Safari” — modal Safari view appears

Stretch goals

  1. Search bar with debounced filtering of loaded articles (use Combine’s .debounce per chapter 4.10).
  2. Image loading — async-load imageUrl into a cell UIImageView with caching (URLCache). Add a fixed-width image on the leading side of the cell content.
  3. Compositional layout — convert from UITableView to UICollectionView with .list(using:).
  4. Section by date — group by today/yesterday/last week using a custom Section enum.
  5. Offline cache — on successful fetch, save articles to Library/Application Support/articles.json. On launch, load and display while fetching fresh. Per chapter 4.7.
  6. Unit tests — inject a mock URLSession via URLProtocol (per chapter 4.8) and write tests for NewsAPI.fetchArticles() success, decoding error, network error.

Notes & troubleshooting

  • “App Transport Security blocked the request”: Spaceflight News API is HTTPS, so no issue. If you swap APIs, ensure HTTPS or add an Info.plist exception (don’t ship that).
  • Dates parse oddly: API returns ISO 8601 with milliseconds. If the default .iso8601 strategy fails, use a custom DateFormatter with "yyyy-MM-dd'T'HH:mm:ss.SSSZ".
  • Pull-to-refresh doesn’t appear: Make sure refreshControl is set on the tableView before the view loads, and the table is a direct subview of the VC (not nested in a scroll view).
  • Cells truncate: Check numberOfLines = 0 on the labels and rowHeight = UITableView.automaticDimension on the table.

Next: Lab 4.2 — Custom collection layout