4.
Developing UI: iOS SwiftUI
Written by Kevin D Moore
As you learned in the last chapter, KMP does not provide a framework for developing UI. You’ll need to use a different framework for each platform. In this chapter, you’ll learn about writing the UI for iOS with SwiftUI. SwiftUI is a declarative UI toolkit which works on iOS, macOS, watchOS and tvOS. This won’t be an extensive discussion on SwiftUI, but it will teach you the basics.
Open the starter project from this chapter. It has a few extra files. This chapter assumes you’re working on a Mac with the Xcode app from Apple. If you’re not on a Mac, feel free to skip this chapter. If you don’t have Xcode, you can open any Swift files in Android Studio.
IDE
Xcode is Apple’s IDE for iOS, iPadOS, watchOS, macOS and tvOS development. In this chapter, you can edit your Swift files in either Xcode or Android Studio. Android Studio has a good editor, but Xcode has the ability to preview your SwiftUI Views for you. The choice of the IDE is up to you and this section will walk you through using both the IDEs.
Android Studio
Open the starter project in Android Studio and select the iOS configuration. You might see a red x in the icon.
Select Edit Configurations… from the drop-down menu. You’ll see:
Select a phone and a target, such as iPhone 13 | iOS 15.0 and click OK.
Now, click the hammer icon (or press Command-F9) to build. This will create the shared framework needed for iOS.
Xcode
Launch Xcode and open the iosApp directory under the starter project for this chapter. You don’t have to select the xcodeproj or the xcworkspace file. Click Open.
Once the project is open, you’ll see the two iosApp folders on the left:
Current UI system
On iOS, you would typically use storyboards or create your UI in code if you were developing in UIKit to design your UI. Underneath those storyboards is a complex XML file. While the layout editor in Xcode is nice, it still takes quite a bit of work to design and then hook up to code. SwiftUI is a declarative UI system that’s written entirely in code. No layouts or storyboards. It’s a lot simpler to use and allows a lot of code reuse with smaller views. Xcode provides previews so that you can build small components and view them next to the editor.
Getting to know SwiftUI
Creating the project using the KMM plugin creates two Swift files: ContentView.swift and iOSApp.swift. These two files are like a “Hello World” app. They show a text field in the center of the screen with the word “Hello”. The plugin adds several files to make development easier.
App
Open iOSApp.swift:
The starting point in a SwiftUI app is a struct that’s marked with the @main
attribute above the struct. This struct usually implements the App
protocol. The App
protocol requires you to create a variable named body
that returns a Scene
. A Scene
is a container for the root view of a view hierarchy. A WindowGroup
is a Scene
and is also a container for your views. On iOS, this will contain only one window, but on macOS and iPadOS, it can contain multiple windows. Since it’s a single expression, a return isn’t required. None of the names of these files are special — the only important piece is to instruct the compiler where to start the app, and you do that with the @main
tag.
Since iOSApp
doesn’t describe what your app does, rename the file to TimezoneApp
by selecting it in the left sidebar and pressing Return. Type TimezoneApp
and press return again. Next, change struct iOSApp: App
to struct TimezoneApp: App
.
Next, add the following code before var body
to change the color of the tab bar to a nice shade of blue:
init() {
let tabBarItemAppearance = UITabBarItemAppearance()
tabBarItemAppearance.configureWithDefault(for: .stacked)
tabBarItemAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.black]
tabBarItemAppearance.selected.titleTextAttributes = [.foregroundColor: UIColor.white]
tabBarItemAppearance.normal.iconColor = .black
tabBarItemAppearance.selected.iconColor = .white
let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.stackedLayoutAppearance = tabBarItemAppearance
appearance.backgroundColor = .systemBlue
UITabBar.appearance().standardAppearance = appearance
if #available(iOS 15.0, *) {
UITabBar.appearance().scrollEdgeAppearance = appearance
}
}
Build the app from the Product menu.
Next, run the app in an iPhone simulator.
It should look like this:
You can also run the app in Android Studio. In Android Studio, make sure iOSApp is selected from the configuration menu:
Then, press the Run button:
ContentView
Open ContentView.swift. Delete Text(“Hello”)
. Add the following as the first line in the struct:
@StateObject private var timezoneItems = TimezoneItems()
Like remember
in Jetpack Compose (JC), StateObject
creates an observable object that’s created once. Each time the view is redrawn, it will reuse the existing object. Other objects can listen for changes, and SwiftUI will update those objects. If you open the TimezoneItems.swift file, you’ll see that it’s an ObservableObject
that Publishes a list of time zones and selected time zones. It also asynchronously gets the list of time zones from the shared
library.
TabView
TabView
is the SwiftUI equivalent of Jetpack Compose’s BottomNavigation
. You can use it to display a tab bar at the bottom of the screen and lets the user switch between different views of the app.
Back in ContentView.swift, define body
as follows:
var body: some View {
// 1
TabView {
// 2
TimezoneView()
// 3
.tabItem {
Label("Time Zones", systemImage: "network")
}
// 4
// FindMeeting()
// .tabItem {
// Label("Find Meeting", systemImage: "clock")
// }
}
.accentColor(Color.white)
// 5
.environmentObject(timezoneItems)
}
- Create a SwiftUI
TabView
. - The first tab will be the
TimezoneView
that you’ll create next. - Apply the
tabItem
with a system network icon and the word Time Zones. - The second tab will be the
FindMeeting
view that you haven’t created yet. (It’s commented out for now.) - Set the
timezoneItems
object as anenvironmentObject
.
There are several ways to pass objects around to different views. Here, you pass timezoneItems
via an Environment Object. The users of this object i.e, any child view, will declare an @EnvironmentObject
variable that will receive that object.
Time zone view
Right-click in the iosApp folder and select New File….
Next, select SwiftUI file and click Next:
Then, save as TimezoneView.swift:
Inside the file, first add the import for the shared library:
import shared
Inside of struct TimezoneView add the following variables:
// 1
@EnvironmentObject private var timezoneItems: TimezoneItems
// 2
private var timezoneHelper = TimeZoneHelperImpl()
// 3
@State private var currentDate = Date()
// 4
let timer = Timer.publish(every: 1000, on: .main, in: .common).autoconnect()
// 5
@State private var showTimezoneDialog = false
- This is the
timezoneItems
object passed in fromContentView
. - Create an instance of
timezoneHelper
. - Get the current date.
- Create a timer to update every second.
- State variable on whether to show the time zone dialog.
@State
is used with simple struct types, and its state is saved between redraws. Any @State
property wrapper means the current view owns this data. SwiftUI keeps track of when this @State
variable changes and redraws the view when its value changes.
@StateObject
is used with classes. You’ll mostly see @State
used as SwiftUI views are struct
s.
Replace Text("Hello, World")
with the following code:
// 1
NavigationView {
// 2
VStack {
// 3
TimeCard(timezone: timezoneHelper.currentTimeZone(),
time: DateFormatter.short.string(from: currentDate),
date: DateFormatter.long.string(from: currentDate))
Spacer()
// TODO: Add List
} // VStack
// 4
.onReceive(timer) { input in
currentDate = input
}
.navigationTitle("World Clocks")
// TODO: Add toolbar
} // NavigationView
- A
NavigationView
allows you to display new screens with a title and will animate the view. - A
VStack
is a vertical stack. It’s basically the same as a Column in JC. - Call the
TimeCard
class to show the time zone in a nice card format. Use theshort
andlong
DateFormatter extensions from theUtils
class. - Use your timer. Every time the timer changes, update the date, which will then update the other elements.
If you look at the Utils.swift file, you’ll see the definition of the short
and long
DateFormatter
extension fields. Go ahead and run the app. Here’s what it will look like:
List of time zones
Next, replace // TODO: Add List
with:
// 1
List {
// 2
ForEach(Array(timezoneItems.selectedTimezones), id: \.self) { timezone in
// 3
NumberTimeCard(timezone: timezone,
time: timezoneHelper.getTime(timezoneId: timezone),
hours: "\(timezoneHelper.hoursFromTimeZone(otherTimeZoneId: timezone)) hours from local",
date: timezoneHelper.getDate(timezoneId: timezone))
.withListModifier()
} // ForEach
// 4
.onDelete(perform: deleteItems)
} // List
// 5
.listStyle(.plain)
Spacer()
- Create a
List
of items. - Create an array of selected time zones, and create a card for each one.
- Show the time zone in a nice time card. Use a custom list modifier to remove the row separator and insets. (See ListModifier.swift.)
- Add the ability to swipe to delete. You’ll define the
deleteItems
method later. - Make the list style plain.
The ForEach
is a special SwiftUI view struct and can be returned as a View
, unlike a regular forEach()
function.
Next, // TODO: Add toolbar
with the following code:
// 1
.toolbar {
// 2
ToolbarItem(placement: .navigationBarTrailing) {
// 3
Button(action: {
showTimezoneDialog = true
}) {
Image(systemName: "plus")
.frame(alignment: .trailing)
.foregroundColor(.black)
}
} // ToolbarItem
} // toolbar
- Add a Toolbar item to the NavigationView.
- Place it on the trailing edge (right side for languages that read left to right).
- Create a Button with a plus sign that will set the
showTimezoneDialog
variable to true.
Next, add the following code after // NavigationView
:
.fullScreenCover(isPresented: $showTimezoneDialog) {
TimezoneDialog()
.environmentObject(timezoneItems)
}
fullScreenCover
is a way to present a full screen modal view over your current view. This will show the time zone dialog as a full-screen sheet. Since it’s modal, there has to be a way to dismiss it. So, there’s a dismiss button in the dialog for that.
The button in the toolbar sets the showTimezoneDialog
variable to true, which is a state variable managed by SwiftUI. When this value changes, the full screen modal is shown.
Next, add the deleteItems
method after the var body
code:
func deleteItems(at offsets: IndexSet) {
let timezoneArray = Array(timezoneItems.selectedTimezones)
for index in offsets {
let element = timezoneArray[index]
timezoneItems.selectedTimezones.remove(element)
}
}
The code above goes through the indices in the IndexSet
, finds the time zone selected, and removes it from your selected list. Build and run the app. Click the + button at the top.
You will see:
Try searching for your favorite time zones, select the time zone and then search again.
When you search for New York, here’s what you’ll see:
When you’re finished, tap the Dismiss button.
This is what it looks like with New York and Lisbon:
If you want to delete a time zone, simply swipe left:
Hour sheet
You’ll want to show the hours that are available to meet. You can do that by showing the hours in a sheet, which in this case, is a modal dialog). This is a simple view with a list of hours and a dismiss button. Create a new SwiftUI View named HourSheet.swift in the iosApp folder. Remove the Text
view, and then add the following two variables right at the beginning of the view:
@Binding var hours: [Int]
@Environment(\.presentationMode) var presentationMode
The first variable is an array of hours the caller will pass in. The second one is the showHoursDialog
Boolean
. This will hide the dialog by setting this variable to false. Add the following inside body
:
// 1
NavigationView {
// 2
VStack {
// 3
List {
// 4
ForEach(hours, id: \.self) { hour in
Text("\(hour)")
}
} // List
} // VStack
.navigationTitle("Found Meeting Hours")
// 5
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
presentationMode.wrappedValue.dismiss()
}) {
Text("Dismiss")
.frame(alignment: .trailing)
.foregroundColor(.black)
}
} // ToolbarItem
} // toolbar
} // NavigationView
- Use a
NavigationView
to show a toolbar. - Use a
VStack
for the title. - Use a
List
to show each hour. - Use the
ForEach
view to show a text view for each hour. - Show a Toolbar with a Dismiss button.
This creates a list for each hour and shows it in a Text
view. To get the preview to work, change the HourSheet()
constructor inside HourSheet_Previews
to:
HourSheet(hours: .constant([8, 9, 10]))
Find meeting
The next screen is the find meeting screen. This is the screen where you can choose the hours you want to search for meetings and then find the hours that work for everyone. Create a new SwiftUI View file named FindMeeting.swift.
First, add the shared
import:
import shared
Then, remove Text("Hello, World")
and add the following variables:
// 1
@EnvironmentObject private var timezoneItems: TimezoneItems
// 2
private var timezoneHelper = TimeZoneHelperImpl()
// 3
@State private var meetingHours: [Int] = []
@State private var showHoursDialog = false
// 4
@State private var startDate = Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: Date())!
@State private var endDate = Calendar.current.date(bySettingHour: 17, minute: 0, second: 0, of: Date())!
- Create a
timezoneItems
environment variable. This will come from ContentView. - Create an instance of the
TimeZoneHelperImpl
class. - An array for meeting hours that all can meet at.
- Start and end dates that are 8 a.m. and 5 p.m.
This gives us all the variables you’ll need for you screen. Now you can start work on the body
. Add the following code inside body
:
NavigationView {
VStack {
Spacer()
.frame(height: 8)
// TODO: Add Form
} // VStack
.navigationTitle("Find Meeting Time")
// TODO: Add sheet
} // NavigationView
This will be a vertical stack with a navigation view, which has a title and some spacers around the title. Now, add the form that has two sections: a time range with the start and end time pickers and the list of time zones selected. Replace TODO: Add Form
with:
Form {
Section(header: Text("Time Range")) {
// 1
DatePicker("Start Time", selection: $startDate, displayedComponents: .hourAndMinute)
// 2
DatePicker("End Time", selection: $endDate, displayedComponents: .hourAndMinute)
}
Section(header: Text("Time Zones")) {
// 3
ForEach(Array(timezoneItems.selectedTimezones), id: \.self) { timezone in
HStack {
Text(timezone)
Spacer()
}
}
}
} // Form
// TODO: Add Button
- Start time date picker.
- End time date picker.
- List of selected time zones.
Now comes the button that does the time zone calculation. It will call the shared
library’s search
method. Replace // TODO: Add Button
with:
Spacer()
Button(action: {
// 1
meetingHours.removeAll()
// 2
let startHour = Calendar.current.component(.hour, from: startDate)
let endHour = Calendar.current.component(.hour, from: endDate)
// 3
let hours = timezoneHelper.search(
startHour: Int32(startHour),
endHour: Int32(endHour),
timezoneStrings: Array(timezoneItems.selectedTimezones))
// 4
let hourInts = hours.map { kotinHour in
Int(truncating: kotinHour)
}
meetingHours += hourInts
// 5
showHoursDialog = true
}, label: {
Text("Search")
.foregroundColor(Color.black)
})
Spacer()
.frame(height: 8)
- Clear your array of any previous values.
- Get the start and end hours.
- Call the shared library search method, converting the hours to ints.
- Create another array of ints from the hours returned. Convert to iOS ints.
- Set the flag to show the hours dialog.
Notice that there is a bit of conversion going on. You need to convert the Swift Int to 32bit Int for Kotlin. Then, when you get the value back from the shared
library, you need to convert the values back to Swift Int. Now that the button sets the flag to show the hours dialog, you need a way of showing that dialog. You’ll use a sheet — a type of dialog that shows up at the bottom of the screen. Replace // TODO: Add sheet
with:
.sheet(isPresented: $showHoursDialog) {
HourSheet(hours: $meetingHours)
}
You are almost there. Finally, you need to add the Find Meeting tab to the TabView.
ContentView
Return to ContentView and un-comment-out the FindMeeting
section.
Build and run the app. Try to add several time zones. Go to the FindMeeting page, tap the Search button and see if any hours show up. If you have problems and don’t see any hours, start with one time zone and work your way up to more. It’s quite possible that there are no compatible hours. Try increasing your end time to 17 or 19. That will increase the range. Here’s an example of hours between Los Angeles and New York time zones:
Congratulations! You now have both an Android and iOS app that you can show off to your friends.
Key points
-
SwiftUI is a new declarative way to create UIs for Apple platforms.
-
You can use Xcode or Android Studio to develop your SwiftUI code.
-
Use
@State
,@StateObject
,@ObservedObject
and@EnvironmentObject
for holding state. -
Use SwiftUI views like
VStack
,HStack
,NavigationView
andText
to build your UIs. -
Use
List
views to show many items. -
ForEach
can return a view which you can use insideList
as well as other views. -
Use
sheet
andfullScreenCover
for modal dialog-type screens. -
Use
Int32
to convert integers for Kotlin. -
Use
Int
to convert Kotlin integers to Swift integers.
Where to go from here?
To learn about:
Xcode: https://developer.apple.com/xcode/
SwiftUI:
- The SwiftUI Apprentice book: https://www.raywenderlich.com/books/swiftui-apprentice
- Official SwiftUI documentation: https://developer.apple.com/documentation/swiftui/
- The raywenderlich.com video course library on SwiftUI: https://www.raywenderlich.com/library?q=swiftui&domain_ids%5B%5D=1&content_types%5B%5D=collection
- @StateObject documentation: https://developer.apple.com/documentation/swiftui/stateobject
Congratulations! You’ve written a SwiftUI app that uses a shared library for the business logic. Now that you have both the Android and the iOS apps written, the next chapter will show you how to create a macOS app.