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 screen —
UITableView(orUICollectionViewwith 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
- New Xcode project: App template, name
NewsReader, language Swift, interface Storyboard, lifecycle UIKit App Delegate. - 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. - 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.jsonthen per-itemhttps://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
- Search bar with debounced filtering of loaded articles (use Combine’s
.debounceper chapter 4.10). - Image loading — async-load
imageUrlinto a cellUIImageViewwith caching (URLCache). Add a fixed-width image on the leading side of the cell content. - Compositional layout — convert from
UITableViewtoUICollectionViewwith.list(using:). - Section by date — group by today/yesterday/last week using a custom
Sectionenum. - 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. - Unit tests — inject a mock
URLSessionviaURLProtocol(per chapter 4.8) and write tests forNewsAPI.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.plistexception (don’t ship that). - Dates parse oddly: API returns ISO 8601 with milliseconds. If the default
.iso8601strategy fails, use a customDateFormatterwith"yyyy-MM-dd'T'HH:mm:ss.SSSZ". - Pull-to-refresh doesn’t appear: Make sure
refreshControlis set on thetableViewbefore the view loads, and the table is a direct subview of the VC (not nested in a scroll view). - Cells truncate: Check
numberOfLines = 0on the labels androwHeight = UITableView.automaticDimensionon the table.