Adding a Feature to an Old Codebase Without Restructuring It
An old Objective-C and Swift project has several kinds of WKWebView screen and no shared WebView base class. Here is how a protocol and extension added a PDF download button with one new line per screen, and where that approach falls short.
On this page
Here’s a problem to think about before reading on.
You’re working in an old iOS project that mixes Objective-C and Swift. It has several kinds of WebView screen, built at different times, and they don’t share a WebView base class. You need to add one feature: when a page turns out to be a PDF, a download button appears in the navigation bar, and tapping it downloads the file.
How would you build it?
The obvious options
When I ran into this, the project had three separate WebView inheritance lines. Two of them shared a general view controller parent, but that parent had nothing to do with WebViews. The third was a Swift class that inherited directly from UIViewController.
Two answers come to mind first:
- Create one WebView base class and move everything under it. The feature then lives in one place, but you’ve changed the inheritance of old screens to ship a small button. Every screen on those lines now needs retesting.
- Write the feature in each controller. Nothing old gets restructured, but the same download code now exists in several places, and each new screen means another copy.
What I wanted was one copy of the logic and almost no changes to the existing classes.
Protocol + extension
The answer I went with was a protocol with a default implementation. The protocol says what a screen must provide. The extension holds the feature.
@objc protocol PDFDownloadable where Self: UIViewController {
@objc func showDownloadButton(url: URL)
func handleDownloadStart()
func handleDownloadSuccess()
func handleDownloadFailure(_ error: NSError)
}
extension URL {
var isPDF: Bool { pathExtension == "pdf" }
}
@MainActor
extension PDFDownloadable {
func addDownloadButton(url: URL) {
guard url.isPDF else {
navigationItem.rightBarButtonItem = nil
return
}
let button = UIButton(frame: CGRect(x: 0, y: 0, width: 24, height: 24))
button.setImage(UIImage(systemName: "square.and.arrow.down"), for: .normal)
button.addAction(UIAction { [weak self] _ in
self?.downloadAndShare(url)
}, for: .touchUpInside)
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: button)
}
private func downloadAndShare(_ url: URL) {
Task {
do {
handleDownloadStart()
let file = try await downloadPDF(from: url)
handleDownloadSuccess()
present(UIActivityViewController(activityItems: [file], applicationActivities: nil), animated: true)
} catch {
handleDownloadFailure(error as NSError)
}
}
}
private func downloadPDF(from url: URL) async throws -> URL {
let destination = FileManager.default.temporaryDirectory
.appendingPathComponent(url.lastPathComponent)
return try await withCheckedThrowingContinuation { continuation in
URLSession.shared.downloadTask(with: url) { tempURL, _, error in
do {
if let error { throw error }
guard let tempURL else { throw URLError(.badServerResponse) }
// The system deletes tempURL once this closure returns.
try? FileManager.default.removeItem(at: destination)
try FileManager.default.copyItem(at: tempURL, to: destination)
continuation.resume(returning: destination)
} catch {
continuation.resume(throwing: error)
}
}.resume()
}
}
}
The button, the download, and the share sheet are all in one place, and none of it knows which screen it’s running on.
Why this barely touches the old code
Conforming doesn’t need any change to the class body. It goes in its own Swift file, as an extension of the existing controller:
extension LegacyWebViewController: PDFDownloadable {
@objc func showDownloadButton(url: URL) {
addDownloadButton(url: url)
}
func handleDownloadStart() { showLoadingView() }
func handleDownloadSuccess() { hideLoadingView() }
func handleDownloadFailure(_ error: NSError) {
hideLoadingView()
reportError(error)
}
}
The only edit to the original class is one line in its Objective-C navigation callback:
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
// ...existing code...
[self showDownloadButtonWithUrl:webView.URL];
}
It goes in didFinish because a WebView can redirect or follow a link, so the URL it ends up on is only known once loading finishes.
No inheritance changed, and no existing method was restructured. If the feature had to come out, you’d delete one line per screen and the extension files.
Why it’s easy to extend
Adding another screen takes the same two steps: one extension file and one line in didFinish. The shared code doesn’t change.
The three handle methods are there because each screen has its own idea of what should happen. One might show a global loading view, another its own. Error reporting belongs to the host too. The shared code only says when these moments happen, not what they look like.
The catch in a mixed project
Objective-C can’t see the default implementations in a Swift protocol extension. So addDownloadButton(url:) is invisible to the .m file that needs to call it.
That’s why the protocol has showDownloadButton(url:). It’s marked @objc, each conforming screen implements it, and all it does is forward to the default implementation. It looks like boilerplate, but it’s the bridge that lets Objective-C code reach logic written in a Swift extension.
Why not a helper object?
This is the fair objection, and it gets its own section because it’s the real competitor. Instead of a protocol, each screen could own a small download helper and pass it closures for loading and errors. The nearly identical extensions would mostly disappear.
The cost is that each screen has to hold that helper. A Swift extension can’t add stored properties to a class, so that means another edit to each old class. The protocol needs no stored state on the host, which keeps the change to one line.
Looking at it now, it’s a close call. With two screens connected, the protocol costs a little repetition. With many more, I’d look at the helper first.
What I gave up, and what isn’t great
I did try pulling this into a local Swift Package. A day later it was back in a folder in the main project, because the team asked to keep it there. So the code is reusable by design, but it isn’t physically separated from the app.
Some things I’d still point out in review:
- The two extensions are nearly identical. I connected two of the inheritance lines, and their extensions differ very little. The third line isn’t connected at all, so this doesn’t cover every WebView in the project.
- The download ignores the HTTP status.
downloadTaskdoesn’t treat a 404 as an error, so a server’s error page would be saved as a.pdfand offered for sharing. isPDFis crude. It only reads the URL’s path extension and it’s case-sensitive, soFILE.PDFis missed, and so is a PDF served from a URL with no extension.- The shared code leans on the main project. In the real code the button image comes from the app’s asset catalog, so the feature can’t move out without taking care of that first.
URLSession.shareddoesn’t carry the WebView’s cookies. A PDF behind a login would need more work.
The protocol doesn’t remove any of this. What it gave me was a way to add the feature to old screens without having to restructure them first.