Chapters

Hide chapters

Kotlin Multiplatform by Tutorials

First Edition · Android 12, iOS 15, Desktop · Kotlin 1.6.10 · Android Studio Bumblebee

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.

Fig. 4.1 - Android Studio iosApp Configuration
Fig. 4.1 - Android Studio iosApp Configuration

Select Edit Configurations… from the drop-down menu. You’ll see:

Fig. 4.2 - Android Studio Edit Configuration
Fig. 4.2 - Android Studio Edit Configuration

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.

Fig. 4.3 - Android Studio Build Button
Fig. 4.3 - Android Studio Build Button

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.

Fig. 4.4 - Xcode Open Project Dialog
Fig. 4.4 - Xcode Open Project Dialog

Once the project is open, you’ll see the two iosApp folders on the left:

Fig. 4.5 - Xcode Project Files Sidebar
Fig. 4.5 - Xcode Project Files Sidebar

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:

Fig. 4.6 - iOSApp.swift
Fig. 4.6 - 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.

Fig. 4.7 - Xcode Build Menu Item
Fig. 4.7 - Xcode Build Menu Item

Next, run the app in an iPhone simulator.

Fig. 4.8 - Xcode Run Button
Fig. 4.8 - Xcode Run Button

It should look like this:

Fig. 4.9 - Starter Screen on iOS
Fig. 4.9 - Starter Screen on iOS

You can also run the app in Android Studio. In Android Studio, make sure iOSApp is selected from the configuration menu:

Fig. 4.10 - Android Studio Configuration List
Fig. 4.10 - Android Studio Configuration List

Then, press the Run button:

Fig. 4.11 - Android Studio Run Button
Fig. 4.11 - Android Studio 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)
  }
  1. Create a SwiftUI TabView.
  2. The first tab will be the TimezoneView that you’ll create next.
  3. Apply the tabItem with a system network icon and the word Time Zones.
  4. The second tab will be the FindMeeting view that you haven’t created yet. (It’s commented out for now.)
  5. Set the timezoneItems object as an environmentObject.

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….

Fig. 4.12 - Xcode File Options
Fig. 4.12 - Xcode File Options

Next, select SwiftUI file and click Next:

Fig. 4.13 - Xcode New File Type Dialog
Fig. 4.13 - Xcode New File Type Dialog

Then, save as TimezoneView.swift:

Fig. 4.14 - Xcode New File Name Dialog
Fig. 4.14 - Xcode New File Name Dialog

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
  1. This is the timezoneItems object passed in from ContentView.
  2. Create an instance of timezoneHelper.
  3. Get the current date.
  4. Create a timer to update every second.
  5. 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 structs.

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
  1. A NavigationView allows you to display new screens with a title and will animate the view.
  2. A VStack is a vertical stack. It’s basically the same as a Column in JC.
  3. Call the TimeCard class to show the time zone in a nice card format. Use the short and long DateFormatter extensions from the Utils class.
  4. 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:

Fig. 4.15 - World Clocks Screen
Fig. 4.15 - World Clocks Screen

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()
  1. Create a List of items.
  2. Create an array of selected time zones, and create a card for each one.
  3. Show the time zone in a nice time card. Use a custom list modifier to remove the row separator and insets. (See ListModifier.swift.)
  4. Add the ability to swipe to delete. You’ll define the deleteItems method later.
  5. 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
  1. Add a Toolbar item to the NavigationView.
  2. Place it on the trailing edge (right side for languages that read left to right).
  3. 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:

Fig. 4.16 - Search Time Zones Screen
Fig. 4.16 - Search Time Zones Screen

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:

Fig. 4.17 - Search Time Zones Screen
Fig. 4.17 - Search Time Zones Screen

When you’re finished, tap the Dismiss button.

This is what it looks like with New York and Lisbon:

Fig. 4.18 - Selected Time Zones
Fig. 4.18 - Selected Time Zones

If you want to delete a time zone, simply swipe left:

Fig. 4.19 - Delete Selected Time Zone
Fig. 4.19 - Delete Selected Time Zone

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
  1. Use a NavigationView to show a toolbar.
  2. Use a VStack for the title.
  3. Use a List to show each hour.
  4. Use the ForEach view to show a text view for each hour.
  5. 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())!
  1. Create a timezoneItems environment variable. This will come from ContentView.
  2. Create an instance of the TimeZoneHelperImpl class.
  3. An array for meeting hours that all can meet at.
  4. 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
  1. Start time date picker.
  2. End time date picker.
  3. 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)
  1. Clear your array of any previous values.
  2. Get the start and end hours.
  3. Call the shared library search method, converting the hours to ints.
  4. Create another array of ints from the hours returned. Convert to iOS ints.
  5. 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:

Fig. 4.20 - Found Meeting Hours Screen
Fig. 4.20 - Found Meeting Hours Screen

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 and Text to build your UIs.

  • Use List views to show many items.

  • ForEach can return a view which you can use inside List as well as other views.

  • Use sheet and fullScreenCover 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:

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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.