Lab 2: TempConverter
Note: this version on the TempConverter app is being built on an iPad using the Playgrounds app. We will spend more time in this lab going through steps with lots of screenshots, because this hasn't been demo'ed in class (because most are using Xcode). If you want to build this with Xcode, please go to the original lab writeup.
This lab will build off our TempConverter that we worked with in 67-272 and in our previous lab and turn it into a functional iOS app. The app will take some user input, convert it (if valid) from either Celsius to Fahrenheit or Fahrenheit to Celsius. There will also be a toggle switch to change the direction of the conversion and a simple "info" page that describes your app. This is what your app might look like when you are done:
One of the main goals of this lab is to give you an introduction into SwiftUI. SwiftUI is another way of building user interfaces in iOS (other than the Storyboards you used last week in lab).
Part 1: App creation and model building
- Open the Playgrounds app and click on the
App
button in the far lower left of the app. This will create a new "My App" project for you. (See screenshot below)
Hold down on that new app and some options will appear, including the first to rename. Do that now to rename to "TempConverter"
- Press and hold on the TempConverter app and it will open and show you some default code for a simple
ContentView
, as can be seen below:
You can run this default app by clicking on the solid blue triangle in the upper left of the screen. You will simply see the globe icon and the typical Hello World message in the middle of the screen. To stop this and go back to code, press the orange Swift button in the upper right side:
When you look at the options, you might also notice something about the name of the app:
As you can see, despite renaming it to TempConverter, it is still called My App. That's because our earlier renaming was just impacted the display in the workspace and not the app itself. Go back to code and click on the navigator icon between the X and the blue triangle in the upper left. This will give you a list of files and a button on the top to app settings. Click on the app settings button and change the name from My App to TempConverter. Feel free to change icon to one provided or even a photo from your library.
- Now that we have some of these basics under our belt, time to get to work. Create a new
Models
folder in the navigator by holding down the Code label and choosing the Add Folder option:
Create two more folders, one called Controllers, and the last called Views. Move the ContentView and the MyApp files into the Views folder.
- Create a new Swift file inside the Models folder called
TempConverter.swift
to hold your model.
The model should:
- Be able to convert Fahrenheit temperatures to Celsius
- Be able to convert Celsius temperatures to Fahrenheit
- Have a variable to interact with the view controller; to pass the converted temperature as an
Int
. Should be the final temperature after conversion or should be nil
if the temperature the user inputs is invalid either because it is below absolute zero or it is not a number.
There is a partial model outline at the end of the lab if you are stuck, but I know you can figure out an appropriate model on your own given your previous experience, and I would encourage you to do so to maximize your learning.
One last note, you might find it easier to type longer blocks of code on an external keyboard rather than the one in the iPadOS; I did that for most (but not all) of this lab. Apple sells a keyboard for the iPad, but via Bluetooth you can actually connect a wide array of wireless keyboards to your iPad. You have a wide array of keyboard choices as a result.
- After you have built your model, check out the boilerplate code at the bottom of the instructions just to confirm you are on the right path. This time, we separate out the functions for Fahrenheit -> Celsius and Celsius -> Fahrenheit for fewer conditionals in our code and more readable logic. You don't have to implement your model like this, but at least take a look to get a sense of the differences in architecture between this model and the one we wrote last week.
Part 2 -- Initial interface building
-
Open the ContentView.swift
to work on the interface. Let's mock up the interface using SwiftUI. For right now, let's assume that all temperature conversions are from Celsius to Fahrenheit.
This is the basic idea of what we're looking to do:
-
By default you are given a VStack
, which is a view that arranges its children in a vertical line [VStack docs], and within that you have an Image
("globe") and some Text
("Hello, world"). You can see how this lays out in the preview on the right side of the screen. For now, let's remove all the content within the VStack
-
Now inside the VStack
, create a HStack
, a view that arranges its children in a horizontal line [HStack docs]. We will use this HStack
to align the converted temperature and its label.
-
Inside this HStack
, create 2 Text
elements (1 for the converted temperature and 1 for the unit; later on when we can toggle the units, the unit label will change independently of the converted temperature, so it is better to keep them separate from the start). You can do this by typing Text("Temp")
and Text("ºF")
in HStack
the editor or you could use the +
button in the top right, search for Text
and drag the element directly into the code editor or the canvas. (bonus tip! easily type º
with the option key + 0)
-
In the same VStack
(but not in the HStack
) add a Text
element with the text "Enter Temperature:"
.
-
Create another HStack
under this Text
element for input temperature and its unit. Add a TextField
and a Text
element to this HStack
. Don't worry about the text: Value
yet.
-
Try to figure out how to resize the TextField
. (Hint: Command + click on the element in the editor and select Show SwiftUI Inspector...
.)
-
Create a Button
inside the VStack
but not in any HStack
. Don't worry about the action
right now, but do set the text to Convert
.
-
Add spacing as necessary by using Spacer()
between elements in the VStack
.
-
By this point, this is what the code inside your body
should approximately look like (may differ slightly with the button depending on version of iOS being used):
VStack {
Spacer()
HStack {
Text("Temp")
Text("ºF")
}
Spacer()
Text("Enter Temperature:")
HStack {
TextField("Temp", text: Value)
.frame(width: 90.0)
.multilineTextAlignment(.center)
Text("ºC")
}
Spacer()
Button(action: {}) {
Text("Convert")
}
Spacer()
}
-
Play around with the styling (especially with the SwiftUI Inspector) to make the app a little more appealing. You can change the background color to yellow by creating a ZStack
outside of your main VStack
and adding the line Color.blue.edgesIgnoringSafeArea(.all).opacity(0.80)
just inside the ZStack
before all the main content. (Hint: You can chain .opacity
to the end of the previous statement to make the color less intense.) Make sure to change the AppIcon to the icons provided in this folder. (Ignore everything except the AppIcon files; see last weeks's lab if you don't remember how to set the AppIcon.) Try to get your app to look something like this:
Use .border(Color.white)
after the TextField
to get the outline.
To get the white background behind the button use the following code directly after the Button
:
.padding(.all)
.background(Color.white)
.cornerRadius(15.0)
And here is the code for the gradient (goes after setting the background color):
LinearGradient(
gradient: Gradient(colors: [Color.white, Color.gray]),
startPoint: .topLeading,
endPoint: .bottomTrailing)
.edgesIgnoringSafeArea(.all)
.opacity(0.45)
Part 3 -- Creating an observable controller
-
Once you are happy with the way your interface looks, let's get started on the view controller. Create a new Swift file called ViewController.swift
inside the Controllers folder. Create a class
named ViewController
and make it an ObservableObject
like this:
class ViewController: ObservableObject { }
You can read more about ObservableObjects here, and we will talk more about them later in the semester with Protocol-Oriented Programming, but the important thing to know is that ObservableObjects will allow the view to be notified of any changes that happen in the model and allow the view to refresh automatically and change accordingly to reflect the new data.
-
In our ViewController
, create an instance of TempConverter
to work with.
-
Next, we are going to create some fields that will interface with our view. We are going to use @Published
so they will update our view when they are modified. They should look like this:
@Published var inputTempString: String = "Temp"
@Published var convertedTempString: String = "Temp"
@Published var isConvertingCtoF: Bool = true
Take a moment to look these over and understand what they do.
As we look over this, we see the first is related to the temperature that we are going to input into the textfield box. The second is related to the large display where we show the results of the conversion. The third is also related to the interface -- it will be a toggle switch that allows us to switch units -- but that we haven't added yet because we needed this controller first to avoid errors. The key idea here is that these published variables get updated and any interface that subscribes to this will get that new data as soon as it is available and/or changes.
-
We need a method setInputTempString()
which will take the input string for temperature as an argument and set the property inputTempString
.
-
We need a method setConvertedTempString()
which get the converted temp from the instance of TempConverter()
we created and if it exists, will set our convertedTempString
variable to a string of that value and otherwise set it to "N/A"
-
To finish off our setters, we one to set the units in the instance of TempConverter()
we created. Because you are really new to Swift and enums, assuming you've followed our model suggestions, the following code will work:
func setInputTempUnit() {
isConvertingCtoF ? tempConverter.setInputUnit(.celsius) : tempConverter.setInputUnit(.fahrenheit)
}
-
Now we need to write a convert()
method for the controller that handles the process of converting a temperature. (As we discussed in 67-272, the actual logic of making the conversion will happen in the model object.) Between the methods in our model and methods we've written in the controller, this method is pretty straightforward and easy to write. To implement it, you must:
- cast the
inputTempString
into a Int
. However, since that is an optional and may return nil, we can use nil coalescing to set it to -500 automatically if it is nil.
- set the input temp units, using our method in the controller
- set the input temp in the
tempConverter
object
- use that model object to
convert
the temp
- update the converted temp string, using our method in the controller
- Ideally, we'd have tests for models and controllers to verify, but that will have to come later. In the meantime, inspect this code one last time and ask for TA help if you are uncertain.
Part 4 -- Tying the controller and view together
-
Now let's go back and work on integrating our views with our view controller. Open the ContentView.swift
.
-
Add the following lines just under Struct ContentView: View
:
@ObservedObject var viewController = ViewController()
@State var inputTemp: String = ""
We will access our ViewController
using the first line, and the second is required for our TextField
. Because the viewController is an observed object, any time one of its published properties changes, this view will be notified so the appropriate updates can occur.
Similarly, the @State
is an internal property wrapper struct that just wraps any value to make sure your view will refresh or redraw whenever that value changes.
-
As we mentioned earlier, we need some way to toggle between unit types. Now that we have the view controller, we can add a toggle switch between the Spacer()
and the Button()
with the following code:
HStack(alignment: .center) {
Text("ºF -> ºC")
.fontWeight(.thin)
Toggle(isOn: $viewController.isConvertingCtoF) {
Text("")
}
.labelsHidden()
.frame(width: 50)
.padding()
Text("ºC -> ºF")
.fontWeight(.thin)
}
.padding()
The dollar sign in this instance is creating a binding between this toggle controller and our inConvertingCtoF
property in the controller. As the toggle switches, so will the property in our controller.
-
Now we need to change the converted temperature label, which we previously had as a HStack
, with one part being "Temp" and the other being degrees Fahrenheit. Now we want to change it to the controller's convertedTempString
and alter the units accordingly. It's slightly less code to write it as follows:
if viewController.isConvertingCtoF {
Text("\(viewController.convertedTempString) ºF")
.font(.largeTitle)
.fontWeight(.ultraLight)
} else {
Text("\(viewController.convertedTempString) ºC")
.font(.largeTitle)
.fontWeight(.ultraLight)
}
However, if you want, you can keep the HStack
, set the temp, and change the units depending on the value of isConvertingCtoF
.
-
In the TextField
, change text: Value
to text:$inputTemp
. Again, the dollar sign will create a binding the @State
property wrapper and the contents of this text field. If you want to dive into property wrappers in more detail, Apple has an excellent video on this subject.
-
Within the action
block of the convert Button
, update the values in our instance of ViewController()
from the TextField
(the controller has a method for that) and call the convert
function from our instance of ViewController()
.
Part 5 -- Creating a multi-screen app
-
It would be nice to start working towards apps that have more than one screen. (Last week's app was single-screen and so far, so is this one.) To do that, let's create a simple info page for the app that we can navigate to. This will provide us with a brief introduction into Navigation Views.
-
Wrap everything in the body
of your ContentView
in a NavigationView
.
-
Create a new SwiftUI View
called InfoView.swift
inside the Views folder. Style this view however you would like and add some brief text. If you need some elegant text and can't think of any off the top of your head, the following might do in a pinch: "This is the ever-famous TempConverter turned into a working iOS app. This is a moment of great celebration! People of the Earth, rejoice!"
-
Add the following NavigationLink
right after the final Spacer()
in ContentView.swift
.
NavigationLink(destination: InfoView()) {
Image(systemName: "info.circle")
.foregroundColor(.white)
}
.padding(.bottom, 50)
Run this and see how the back button is automatically created for you when you use a NavigationView
!
The one problem you will see on the iPad is that it is going to want to stick the whole app in the navigation section of the iPad. See the screenshot below:
We can fix this by just adding after the NavigationView{}
-- which should end at the third }
from the bottom of the file -- the following code:
.navigationViewStyle(.stack)
This is essentially telling the app that it can take the whole screen for the app. Run it now and it looks normal.
Now you have an information button and have successfully linked to another page and your app should be fully functional; congrats on creating your first iOS app using SwiftUI on an iPad! We will continue to use this framework for the rest of the semester, so you will become much more familiar with it over time. Qapla'
Addendum -- Model Starter Code
If you need a little push with the model code, here is some starter code with comments to guide you:
class TempConverter {
// Not essential, but makes code a little cleaner later
// See the Enums playground from lecture 2 for more on enums
enum TempUnit: String {
case fahrenheit = "ºF"
case celsius = "ºC"
}
// MARK: Fields
var isConvertingCtoF: Bool = true
var inputTemp: Int = 0
var convertedTemp: Int?
// Checks if the input temperature is below absolute zero
func isBelowAbsoluteZero() -> Bool { }
// Set the input units (using switch-case instead of if-else, although both work)
func setInputUnit(_ tempUnit: TempUnit) { }
// Setter and getter methods
func setInputTemp(_ temp: Int) {
inputTemp = temp
}
func getConvertedTemp() -> Int? {
return convertedTemp
}
// Separated functions for temperature conversion by unit
func convertCtoF() { }
func convertFtoC() { }
// Checks that the value is a valid temp using a guard statement to check if above absolute zero (return nil if not) and calls the appropriate conversion function above
func convert() { }
}