Creating a Tube Status App in SwiftUI
By: Fred Gray
Tags: Swift, Swiftui, Technology, iOS, Programming

Creating a Tube Status App in SwiftUI

Photo by Dan Roizer on Unsplash

Full disclosure — I’m not the best iOS developer, or programmer in general. I’d scarcley got my head around UIKit and the delegate pattern for handling data received from APIs before *boom* I open up Xcode after a break and all of a sudden there’s a view struct staring back at me — not a trusty viewDidLoad method in sight! That’s how I discovered SwiftUI. At first, I was immensely confused — the paradigm shift from the imperative UIKit to the declarative SwiftUI is a real head-scratcher if you’ve still at the level where closures freak you out. But I quickly saw how easy and intuitive it was to create really stunning, flexible user interfaces, and that convinced me it was well worth sticking with it, especially after the enhancements introduced SwiftUI 2 earlier this year.

With that in mind, we’re going to use Transport for London’s API to get information on the staus of London Underground (and DLR) lines. It’s a powerful, well-documented API that can give us all sorts of data about transport in London and beyond. Our app will be super, super simple — it will display all of Tube lines, and their current status, each line presented as a tile:

What our app will look like: colourful line status tiles laid out in a scrollable grid.

1 — Understanding the Data

First of all, we need to understand the structure of the data we’ll get from the API, and design some Codable-compliant data types to decode and parse the JSON data in to. Codable is a type alias for the Encodable and Decodable protocols — we’ll conform to Codable as long as each data type in our object is itself Codable (e.g. String, Int, Double, or a custom data type that itself conforms to Codable).

Below is what the JSON response will look like — it’s an array of objects, each representing a Tube line, which in turn each contain an array of objects (just one, usually) representing the line’s status.

The JSON return we’ll get from our API call, and how the structure maps on to our Codable structs below. I’m using JSON View Awesome extension for Chrome to make it *way* easier to see the structure

struct LineStatus : Codable{ let statusSeverity : Int

let statusSeverityDescription : String

let reason : String? }

struct Line : Codable, Identifiable { let id : String

let name : String

let lineStatuses : [LineStatus]

}

Each Line object has many other elements in its JSON structure that we don’t need for this very basic app, so we don’t need to include them in our Line struct we’ll us with our decoder.

The reason property is an optional because it will not be part of the JSON response for lines that have no disruptions (and therefore no reasons for a disruption). If we just add ‘let reason : String’ to our struct, the decoder will fail as soon as it comes across a LineStatus object with ‘good service’— because it won’t find a key called ‘reason’. Luckily, if we use an optional (let reason : String?), the decoder will simply assign nil to this property if it doesn’t find it in the JSON object.

2- Creating our Data Manager class

We’ll create a class called DataManager which will be responsible for getting the data from the API, decoding it and parsing it into the Line and LineStatus data types we created, ready for our views to use.

Much of this class will look familiar if you’ve ever fetched some JSON data before in Swift: we create a URLRequest object with our URL, pass it to a URLSession data task object, and then deal with the response in a completionHandler closure. Provided we get some data back, we then try and run it through a JSON decoder that tries decoding it into an array of our Line objects.

What we then do with our ready-to-use data is where the difference between UIKit and SwiftUI come in. In UIKit, we’d probably call our delegate’s didReturnData (or whatever you’d name it) method to manually hand over the data to our ViewController.

In SwiftUI, all we have to do is update the value of a property within the class — assigning it to the ‘lines’ property. Our class conforms to the ObservableObject protocol, meaning we can use the @Published property wrapper on our lines variable. When a view initialises an instance of our class (and wraps it in the @ObservedObject property wrapper), it ‘subscribes’ to it, and will be invalidated and refreshed whenever the value of ‘lines’ change — so when we update lines with our received data, the view will be updated to!

class DataManager : ObservableObject{ @Published var lines : [Line] = [] let status_request = "https://api.tfl.gov.uk/Line/Mode/tube,dlr/Status?detail=true" //In our request, the 'mode' parameters list the Tube lines (and the DLR - a light railway line), and we add on detail=true so we get the reasons for any disruptions as well as just the status. func fetchData(){ guard let requestURL = URL(string: self.status_request) else{

return} let request = URLRequest(url: requestURL)

let session = URLSession.shared.dataTask(with: request, completionHandler{ (data, response, error) -> Void in if let error = error{

print(error)

return

} if let data = data{

DispatchQueue.main.async{

self.lines = self.decodeLines(data: data) }

} }) session.resume()

} func decodeLines(data: Data) -> [Line]{

let decoder = JSONDecoder()

var lines : [Line]

do{

lines = try decoder.decode([Line].self, from: data)

} catch {

print(error)

}

return lines } }

3 — Building our custom LineTile view

Now we have our DataManager class ready to go, we can focus on the fun part — the views!

We’re going for a colourful, simple tile-based UI — each Tube line will be displayed in a nice rounded tile with its name and line status. We’ll make a custom SwiftUI view to represent these tiles. Each one will take Line object as an parameter. SwiftUI makes it easy to create views that are dynamic, that adapt to the data that drive them. Therefore, we’ll have a very bold, clean look for Tube lines with no reported issues, and a more informative tile for Tube lines that do have reported distruption:

Our LineTiles will adapt to be clean and simple if possible, but informative if necessary.

The colours of our LineTiles will match the colours that Transport for London use to identify the Tube lines. These colours are immediately recognisable to anyone who frequents the London Underground, so they’re an important part of the design. Transport for London also use white lettering overlaid on these colours, so we know they’ll work with our white text.

Therefore, we want a way to associate each Line object with that the colour of that line (and potentially other information if the app grows). There are many ways to do this, but where we want to represent a manageable numbers of options (e.g. the 12 Tube/DLR lines), an enumerator is a safe, flexible option:

enum TubeLines : String, CaseIterable, Identifiable{ case bakerloo

case central

case hammersmithCity = "hammersmith-city" ....for all the lines.... var lineColour : Color{ switch self{ case .bakerloo:

return Color(UIColor.init(red: 178/255, green: 99/255, blue: 0, alpha: 1)) case .central:

return Color(UIColor.init(red: 220/255, green: 36/255, blue: 31/255, alpha: 1)) .... for all the lines....

} } }

For each case in our TubeLines enum, we have a computed Color property, which just returns the matching colour initialised with the RGB values (as provided by Transport for London here). For the Northern line, it’s ‘official’ colour is pure black (R:0, B:0, G:0), but this would make the tile blend in to the background in dark mode, so we’ll actually give it a very dark grey colour instead.

What we want is to be able to associate each Line object with the correct enum case. We can do this by comparing the rawValues of the enum cases to the id of the line. The raw value of TubeLine.bakerloo is “bakerloo”, so is the id for the Line object for the Bakerloo line. Where the id of a line isn’t compatible with a Swift data type name (e.g. two-word lines such as Hammersmith & City (id is “hammersmith-city”), we can explicitly define the raw value, as above.

We’ll create an extension on String to allow us to quickly return the matching enum case for our Line ids:

extension String{ func lineCase() -> TubeLine{

let lines = TubeLine.allCases

var mapped = TubeLine.unrecognised for line in lines{

if self == line.rawValue{

mapped = line

}

}

return mapped

}

} /// Use as 'line.id.lineCase()'

With these supporting bits of code ready, we can build the LineTile view:

struct LineTile : View{ let line : Line

var currentStatus : LineStatus{

return self.line.lineStatuses[0]} //currentStatus computed property gives us a quick way of getting to the LineStatus object of the Line (as it's nested in an array of which it the first element) @State private var badStatusAnimate = false //We'll use this state property to trigger a 'flashing' effect on LineTiles that have disruptions var body : some View{ ZStack{

RoundedRectangle(cornerRadius: 15)

.foregroundColor(line.id.lineRender().lineColor)

.frame(minHeight: 160, maxHeight: 200)

.opacity(badStatusAnimator ? 0.6 : 1.0)

.animation(Animation.easeInOut(duration: 3).repeatForever(autoreverses: true)) //To animate aspects of our view such as the opacity, we can set the opacity's value to be contingent on the boolean value of 'badStatusAnimator', so when that value changes, the opacity will change - and the .animation modifier defines how that change will look. In this case, it will repeat and reverse, creating a flashing effect. VStack{

HStack{

Text(line.name)

.font(.system(size: 22))

.fontWeight(.heavy)

Spacer()

}.padding(.leading, 10) Spacer() // After adding the name of the line, we will use if-else to determine the rest of the view's layout by seeing if there is any disruption on the line. A StatusSeverity value of 10 means 'good service'': if self.currentStatus.statusSeverity == 10 { HStack{

Spacer()

Text(self.currentStatus.statusSeverityDescription)

.font(.largeTitle)

.fontWeight(.heavy)

.multilineTextAlignment(.trailing)

}

} else { VStack{



HStack{

Image(systemName: "info.circle.fill")

.font(.system(size: 16)

Spacer()

Text(self.currentStatus.statusSeverityDescription)

.font(.system(size: 16)

.fontWeight(.heavy)

Spacer()

}.padding(5)

}



//Even though 'reason' shouldn't be nil if there is disruption, we're going to double check so if it is nil the view will still look OK. The formatReason() is a little String extension that just removes a superfluous reference to the line in the reason string. if self.currentStatus.reason != nil{

HStack{

Text(currentStatus.reason!.formatReason(line))

.font(.footnote)

.fontWeight(.heavy)

.multilineTextAlignment(.center)

Spacer()

}.padding(5)



Spacer()

} // If the VStack containing the disruption information appears, we'll toggle the badStatusAnimate property to true - this will trigger our flashing animation for distrupted lines: }.onAppear(){ self.badStatusAnimate.toggle() } } }

}

}

4 — Brining it all together in ContentView

Now we’ve built our LineTile view, we need to build out our ContentView to request the data and display a container of LineTiles. We will use a LazyVGrid with two columns, contained in a vertical ScrollView, to layout our LineTiles.

Crucially, we create a link between our data and our view by initialising an instance of our DataManager class, wrapping it @ObservedObject to subscribe to its published properties (i.e., the array of Line objects).

struct ContentView: View { @ObservedObject var dataManager = DataManager() //We create an array of GridItems as a blueprint for our LazyVGrid - in this case, two columns of flexibly-sized views: let lineColumns = [GridItem(.flexible()), GridItem(.flexible())] var body: some View{ ScrollView(.vertical, showsIndicators: false){ LazyVGrid(columns: lineColumns, spacing: 20){ ForEach(dataManager.lines){ line in LineTile(line: line)

}

}

}.padding(.horizontal)

.onAppear(){ self.dataManager.fetchData()

}

}

}

All we need to do is call the fetchData( ) method on our dataManager instance to the trigger API call, which in turn will update lines array we’re observing. We can do this in a code block within the .onAppear modifier, which will be executed as soon as the view appears — in this case, we’re using it where we would have used viewDidAppear/viewDidLoad in UIKit. If, for whatever reason, our API call hangs or the connection fails, we’ll just have a blank screen — because the lines property of our dataManager will stay being empty, so they’ll be nothing for the ForEach(dataManager.lines) to do.