Swift Talk # 103

Refactoring Large View Controllers: Extracting Pure Functions

This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We begin refactoring a large view controller from the Wikipedia iOS app by pulling pieces of helper code out as pure functions.

00:06 Today we're starting a new series in which we show some techniques for refactoring large view controllers.

We'll work with a view controller from the open source codebase of the Wikipedia iOS app. The changes we make aren't meant as criticisms. On the contrary: it's because the code is written very well that it's pretty easy for us to understand and use for demonstration purposes in this series.

Wikipedia's Places View Controller

01:33 The view controller we chose to examine is the PlacesViewController, which shows up in the Places tab. It has a map view showing Wikipedia entries as map annotations. When we select an annotation, an overlay appears with a short description and some buttons. There's also a search box with filter options and a list of everything currently on the map. When we type in the search box, it gives us both autocomplete suggestions and our search history. Clearly, there are a lot of features on this screen, and most of them are written in a single view controller using around 2,700 lines of code.

02:59 There can be many reasons to refactor code. We do so because we want to be able to understand the code better, and in general, a smaller view controller is easier to understand. The PlacesViewController has a lot of state, and any of its methods could potentially access that state. This means one has to be very careful when changing a method and make sure to not create any conflicts with other parts of the view controller.

By pulling methods out into separate, logical components or top-level functions, we will hopefully make it easier to see what the view controller is really about. And in doing so, we'll also make the individual components easier to understand.

Pulling Out a Helper Function

04:13 We start with some methods that are easiest to pull out into helper functions. The first method takes two coordinate regions and checks if there's a certain distance between them:

func isDistanceSignificant(betweenRegion searchRegion: MKCoordinateRegion, andRegion visibleRegion: MKCoordinateRegion) -> Bool {
    // ...
}

05:11 By reading the signature, we suspect it can operate independently of everything else in the view controller. But because it exists inside the view controller, we can't be sure that it doesn't rely on other properties. We test this by moving the method into a new file, making it a top-level function. The project still builds after doing so, which confirms our suspicion: the function works without any access to the view controller's state.

06:58 Why does it make sense to remove the function from the view controller? We could argue that it doesn't matter where it lives if it doesn't add any state to the view controller. But the downside of having the function inside the view controller is that we're not sure whether or not it messes with the state.

By moving the function to a separate namespace, we isolate it and we can see exactly which parameters it depends on and what comes out of it. In other words, it's now a pure function because it doesn't have any side effects. Therefore, it's not only easy to understand, but also easy to test.

08:10 To follow a more Swift-like pattern, we put the function in an extension of the type it operates on:

import Foundation
import MapKit

extension MKCoordinateRegion {
    func isDistanceSignificant(to visibleRegion: MKCoordinateRegion) -> Bool {
        // ...
    }
}

09:01 Inside the function, we can now use self anywhere the searchRegion parameter was used. Next, we update the method calls in the view controller:

let movedSignificantly = regionThatFits.isDistanceSignificant(to: visibleRegion)

10:28 Because the function we pulled out is a pure function, we feel comfortable with this refactoring, and we don't need to write any tests for it. And by pulling out this kind of helper code — even if it's specific to just this view controller — we make the code easier to read and process.

Pulling Out Layout Code

11:12 The second example of helper code to pull out is a bit more complicated. When a map annotation is selected, a popover shows up next to it. The position for the popover is calculated based on the superview's size, the search box overlay, the status bar, and other elements. This calculation is done in the view controller method adjustLayout, which does a whole lot of frame math and finally updates the popover's frame. The method depends on a couple of properties:

func adjustLayout(ofPopover articleVC: ArticlePopoverViewController, withSize popoverSize: CGSize, viewSize: CGSize, forAnnotationView annotationView: MapAnnotationView) {
    // ...
    articleVC.view.frame = CGRect(origin: CGPoint(x: x, y: y), size: popoverSize)
}

12:19 The method adjustLayout takes the popover controller and manipulates its view's frame. We want to separate the calculation and the setting of the frame, and in the end, we want to pull out the computation part. But first, we split up the method into two methods side by side:

func adjustLayout(ofPopover articleVC: ArticlePopoverViewController, withSize popoverSize: CGSize, viewSize: CGSize, forAnnotationView annotationView: MapAnnotationView) {
    articleVC.view.frame = computeLayout(for: popoverSize, viewSize: viewSize, forAnnotationView: annotationView)
}

func computeLayout(for popoverSize: CGSize, viewSize: CGSize, forAnnotationView annotationView: MapAnnotationView) -> CGRect {
    // ...
    return CGRect(origin: CGPoint(x: x, y: y), size: popoverSize)
}

14:49 That sets up our refactoring and it still compiles. Now we want to change the method to only take value types, so we have to do something about the MapAnnotationView parameter, which is only used for its frame and its center point. We replace the parameter directly with a frame, and we recompute the center point from the frame:

func adjustLayout(ofPopover articleVC: ArticlePopoverViewController, withSize popoverSize: CGSize, viewSize: CGSize, forAnnotationView annotationView: MapAnnotationView) {
    articleVC.view.frame = computeLayout(for: popoverSize, viewSize: viewSize, forAnnotationView: annotationView.frame)
}

func computeLayout(for popoverSize: CGSize, viewSize: CGSize, forAnnotationView annotationViewFrame: CGRect) -> CGRect {
    // ...

    let annotationSize = annotationViewFrame.size
    let annotationCenter = view.convert(annotationViewFrame.center, from: mapView)

    // ...
}

15:59 CGRect doesn't have a center property, so we add it in another helper file:

extension CGRect {
    var center: CGPoint {
        return CGPoint(x: midX, y: midY)
    }
}

16:56 Now we have a separate helper method that only takes value types, but we don't yet know what kind of dependencies are hiding in the body of computeLayout. We find out by moving the method out of the view controller and into the helper file. The compiler shows errors for all properties that the method can no longer access.

17:23 The first error that pops up is a missing enum type, which actually belongs to the method only, so we move it into the helper file as well:

enum PopoverLocation {
    case top
    case bottom
    case left
    case right
}

17:52 The next error we get is for accessing the view controller's view. The view is used to convert the annotation's center point from the map view's coordinate system:

let annotationCenter = view.convert(annotationViewFrame.center, from: mapView)

We don't have access to the view in our helper function — nor do we want to have access — so we decide that the passed-in frame should already be converted to the view controller's view. This allows us to replace the line above with the following:

let annotationCenter = annotationViewFrame.center

18:51 And we do the conversion in the adjustLayout method of the view controller:

func adjustLayout(ofPopover articleVC: ArticlePopoverViewController, withSize popoverSize: CGSize, viewSize: CGSize, forAnnotationView annotationView: MapAnnotationView) {
    let annotationFrame = view.convert(annotationView.frame, from: mapView)
    articleVC.view.frame = computeLayout(for: popoverSize, viewSize: viewSize, forAnnotationView: annotationFrame)
}

19:27 The next missing dependency is the property isViewModeOverlay. It indicates whether or not the search box overlay is there, which is the case on iPad but not on iPhone. If the search box overlay, listAndSearchOverlayContainerView, is present, we also need its frame. We add an optional parameter for the frame so that we can pass in nil if the overlay isn't present. By taking the frame and not the view, we're still only depending on value types:

func computeLayout(for popoverSize: CGSize, viewSize: CGSize, forAnnotationView annotationViewFrame: CGRect, overlayFrame: CGRect?) -> CGRect {
    // ...
    if let listAndSearchOverlayContainerViewFrame = overlayFrame {
        // ...
    }
    // ...
}

21:10 From the view controller, we pass in either the overlay's frame or nil, depending on the Boolean property isViewModeOverlay:

func adjustLayout(ofPopover articleVC: ArticlePopoverViewController, withSize popoverSize: CGSize, viewSize: CGSize, forAnnotationView annotationView: MapAnnotationView) {
    let annotationFrame = view.convert(annotationView.frame, from: mapView)
    let overlayFrame = isViewModeOverlay ? listAndSearchOverlayContainerView.frame : nil
    articleVC.view.frame = computeLayout(for: popoverSize, viewSize: viewSize, forAnnotationView: annotationFrame, overlayFrame: overlayFrame)
}

21:44 There are two errors left. The first one is caused by accessing the view to calculate its center point:

let viewCenter = CGPoint(x: view.bounds.midX, y: view.bounds.midY)

Since we already know the view's size, we can recreate the view's bounds and use the computed property we wrote earlier to get the center point:

let viewCenter = CGRect(origin: .zero, size: viewSize).center

22:57 The last error is about the missing property extendNavBarView:

let navBarHeight = extendedNavBarView.frame.height

We need the height of the navigation bar, which depends on the device and its orientation, so we pass this in as another parameter:

func computeLayout(for popoverSize: CGSize, viewSize: CGSize, forAnnotationView annotationViewFrame: CGRect, extendedNavBarHeight: CGFloat, overlayFrame: CGRect?) -> CGRect {
    // ...
    let navBarHeight = extendedNavBarHeight
    // ...
}

23:42 After updating the call site, the code compiles again:

func adjustLayout(ofPopover articleVC: ArticlePopoverViewController, withSize popoverSize: CGSize, viewSize: CGSize, forAnnotationView annotationView: MapAnnotationView) {
    // ...
    articleVC.view.frame = computeLayout(for: popoverSize, viewSize: viewSize, forAnnotationView: annotationFrame, extendedNavBarHeight: extendedNavBarView.frame.height, overlayFrame: overlayFrame)
}

Discussion

23:59 These changes were pretty straightforward. We didn't change any of the logic; we changed just the parameters that are passed around. From an object-oriented standpoint, one could argue we made things more complex by pulling out specific logic and having to pass a long list of parameters instead of just accessing the view controller's properties. But on the other hand, we now know for sure that the code we pulled out is only dependent on the parameters we pass in.

24:48 Another advantage of our refactoring is that the computeLayout function only uses value types, which means it's really easy to test: we can simply pass in arbitrary values and check the result we get back.

25:29 Today's examples were the simplest changes we could make to the codebase. And there are more cases like these which can be dealt with in a way that's similar to what we showed, so we won't have to cover those here.

But we will dive into refactoring other, more interesting parts: we'll pull some code out into the model layer, pull other code out into a service class, and maybe even split the whole thing up into child view controllers. We look forward to doing all of this in upcoming episodes!

Resources

  • Wikipedia for iOS

    Written in Swift 4

  • Episode Video

    Become a subscriber to download episode videos.

Related Blogposts

In Collection

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes