iOS

Architecture Overview

We are in the middle of the process of slowly converting all UIKit features into SwiftUI views. Therefore, the UIKit documentation below shall be seen as deprecated.

SwiftUI Architecture

Just like a solid foundation determines the success of building a new house, a good architecture paves the way for a successful project. Given the number of features in this project and the high fluctuation in the development team, we were determined to contstruct and properly document a future-proof architecture for the iOS app.

Lots of thoughts have been given to how and why to strucutre and layout this component. We researched, learned and tried out several architecture styles, including MVVM, VIPER, REDUX, FLUX and Feedback. These are all popular styles for mid- to large-sized applications. We also took a lot of advice from Apple’s WWDC documentations and screencasts. In the end, we settled for something more simple and elegent, namely a well-structured MVVM (Model-View-Viewmodel) pattern. We would like to list the reasons for that below. First, SwiftUI is still rather new and under active development, which means things change rather quickly. In accordance with that, we preferred not to include an external library to handle state management or user actions. Second, the code overhead should be rather small, wherease some patterns require massive Reducers or alike. Third, SwiftUI already has a lot of state management built-in, which we would like to use to our advantage. Further reasons and explanation can also be found in the essay Clean Architecture for SwiftUI.

Before we begin, take a step back and refresh your memory of the global Project Structure of the project. Now, let’s look at the overall structure of the iOS component.

Component Structure

At its core, we split the application into modules regarding the different features (see Overview). Each module is then structured into Models, Views, ViewModels, and Services. The Generic module holds common view components and interface implementations (e.g. for networking).

Module Architecture

A Model is a struct that represents an atomic entity, most commonly of something delivered through our API.

Each Model has an associated ViewModel, which handles user intents (e.g., when the user interacts with a button) and can dispatch calls to services.

A Service provides easy access to system interfaces or network request to our API.

The View is a SwiftUI view that is only concerned with representing the current state to the user. It explicitly does not have any function implementations for handling user intents. However, it can include computed variables and functions that are entirely user interface related (e.g., setting up a swipe gesture).

The AppState is the single source of truth for the entire application. It is passed around between views as a environment object.

Tooling

Note

short description of team specific tools

Testing

In order to test the app on the phone, you simply need to open XCode, plug iPhone to your computer and select your phone as a device. If it says that there is no access to this membership resource, it means you need to sign in at the App Store with the data of the project owner. After that you should be able to run the program again and use your private iCloud.

How to start a new feature

Note

describe how to create a feature

Note

to be done!

Quality Control Measures

Code Style

A consitent code style is important, so that all developers feel at home, even when working on parts of the application not implemented by themselves. We have agreed to use the Google Swift Style Guide. To better enforce the consistent style and conventions, we use SwiftLint.

CI Runner in IOS

Note

The CI runner can only operate on a Mac. When pushing to GitLab, the CI runner is executed on one of the Macs located in the Software Engineering Pool. The documentation below describes how the CI runner can be set up on this Mac. However, the CI runner is currently out of order since the Mac was shut down and used for another project. Consequently, it has to be re-initialized before it can operate again.

This article covers the main aspects about the CI runner for iOS. In contrast to Android, the CI runner on iOS has to run on a local server, the gitlab runner is insufficient since XCODE only works on a MAC. Consequently, the CI runner has to be setup on one of the MACs in the pc pool of the SE institute. For this, a separate user was setup on the “left” MAC in the pool (username “GitLab CI Runner” or something like that). For the pipeline to work, it is necessary that this user does not get logged out and the MAC is never shut down completely. Putting the MAC into sleep mode does work though.

Manual Installation

  • Remove the old runner if necessary from GitLab (Log into GitLab -> iOS -> Settings -> Ci / CD -> Runners -> Remove Runner)

  • execute following commands in the terminal of the “GitLab CI Runner” user

sudo curl --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-amd64
sudo chmod +x /usr/local/bin/gitlab-runner
cd ~
gitlab-runner
install gitlab-runner start

Homebrew Installation

brew install gitlab-runner
brew services start gitlab-runner

Post installment

After setting up the runner, go onto GitLab and start a pipeline. The pipeline might fail. If this is the case, check for the error messages when the build is failing. Here are some possible solutions, but since there might be some errors we did not have, this list might be incomplete.

  • install homebrew

  • install cocoapods

brew install cocoapods
  • install xcpretty

sudo gem install xcpretty
  • Unable to find a destination matching the provided destination specifier:{ platform:iOS Simulator, OS:12.2, name:iPhone 8 } -> If this error occurs, check for the installation of the simulators. If the simulators are installed, check for the iOS versions they use and which iOS versions are used in the gitlab-ci.yaml file in the project. Change the versions of the gitlab-ci.yaml file to fit the iOS versions of the installed simulators.

UI Testing

This sections takes a look on how to swrite UI tests with SwiftUI. All UI tests are located in “UniApp -> UniAppUITests -> UniAppUITests.swift”

Cheatsheet for testing: Cheatsheet

Before each test, the setUp() function is called.

[UniAppUITests.swift]
...
       continueAfterFailure = false

       app = XCUIApplication()
       app.launchArguments.append("--UITesting")
       app.launch()
...

If a tests fails, it is likely that the app is in a wrong or unexpected state. To prevent the tests from continuing to be executed, the continueAfterFailure boolean is set to false. With the “–UItesting” argument the app starts in the “default first launch” configuration, implemented in AppDelegate.swift:

[AppDelegate.swift]
...
func application(_ application: UIApplication,
                    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

       // start app as if its the first launch for UI tests
       if CommandLine.arguments.contains("--UITesting") {
           firstLaunch = FirstLaunch.alwaysFirst()
       }
...

This makes sure every time a UITest is performed, the app is in the same known state.

Example Test

This UITest tests the News part of the “News/Events” feature. First, the corresponding button on the tabBar is tapped to go to the “News/Events” feature. We then check if the app is actually displaying the correct view.

[UniAppUITest.swift]
...
  func testNews() {
    app.tabBars.buttons["News"].tap()
    XCTAssertTrue(app.isDisplayingNewsEvents)
...

This check is outsourced to an extension for improved readability.

[UniAppUITests.swift]
...
  // extension for cleaner test functions
  extension XCUIApplication {
    var isDisplayingNewsEvents: Bool {
      return otherElements["NewsEventsMainView"].waitForExistence(timeout: 1)
  }
...

For the app to be able to identify the “NewsEventsMainView”, an identifier has to be set on the corresponding view.

[NewsEventsMainView.swift]
...
  var body: some View {
    NavigationView {
      ...
    }
      .accessibility(label: Text("NewsEventsMainView"))
  }

The XCTAssertTrue will succeed if the NewsEventsMainView is actually getting displayed. Now, we can try to tap on a news item and then check if the detail view is getting displayed.

[UniAppUITest.swift]
...
  func testNews() {
    app.tabBars.buttons["News"].tap()
    XCTAssertTrue(app.isDisplayingNewsEvents)

    // tap first item in list
    app.tables.element.firstMatch.tap()
    // check if detail view is getting displayed
    XCTAssertTrue(app.isDisplayingNewsItemDetailView)
...

Again, we have to create an extension just as for the NewsEventsMainView.

[UniAppUITests.swift]
...
  // extension for cleaner test functions
  extension XCUIApplication {
    var isDisplayingNewsEvents: Bool {
      return otherElements["NewsEventsMainView"].waitForExistence(timeout: 1)

    var isDisplayingNewsItemDetailView: Bool {
      return scrollViews["NewsItemDetailView"].waitForExistence(timeout: 1)
    }
  }
...

This time, the view we are looking for is a scroll view, since the .accessibility modifier is added to a ScrollView in the NewsItemDetailView

[NewsItemDetailView.swift]
...
  var body: some View {
    ScrollView {
      ...
    }
      .accessibility(label: Text("NewsItemDetailView"))
  }
...

Now we could for example try to tap the Share button of the NewsItemDetailView and close the share menu again.

[UniAppUITest.swift]
...
  func testNews() {
    app.tabBars.buttons["News"].tap()
    XCTAssertTrue(app.isDisplayingNewsEvents)

    // tap first item in list
    app.tables.element.firstMatch.tap()
    // check if detail view is getting displayed
    XCTAssertTrue(app.isDisplayingNewsItemDetailView)

    // tap share button
    app.buttons["share"].tap()
    // try tap to cancel share
    app.otherElements.element(boundBy: 1).buttons.element(boundBy: 0).tap()
...

Feature Implementation

The following will describe development details for each feature in detail. Keep in mind that this part of the documentation only covers implementation details. For more details about the functionalities and requirements of each feature, see the Overview. Additionally, this article does not talk about how each feature is integrated into the application. Details on the architecture can be found above (SwiftUI Architecture).

News / Events

The News / Events feature can be accessed through the button navigation bar. Since the navigation is still handled with UIKit, the feature includes a UIViewController as “starting point” for the feature. When accessing the feature, the UIViewController initialises the main view of the feature, the NewsEventsMainView. This is the UIViewControllers only functionality, the actual feature is completely handled with SwiftUI and the new architecture.

The Main View

News Main View

The main view of the feature is the NewsEventsMainView. This view can be found in “view” -> “screens” and embodies the main screen for the feature. The view body consists of three parts: A NavigationView to provide an adequate title, a pickerView for switching between news and events, and a switchTabView which either displays the list of news or events. NewsEventsMainView:

var body: some View {
  NavigationView {
    VStack {
      pickerView
      Spacer()
      switchTabView
        .frame(alignment: .center)
        .navigationBarTitle(Text(tabBarTitles[self.pickedTab]))
      Spacer()
    }
  }.onAppear {
    self.vm.reloadNews()
    self.vm.reloadEvents()
  }
}

The pickerView and the switchTabView are defined outside of the body to improve readability of the code.

Switching between News and Events

For switching between news and events, the pickerView is simple SwiftUI Picker with the SegmentedPickerStyle. The pickable elements of the picker are stored within a String array called tabBarTitles, which consists of “News” and “Events”. Picking one of the provided possibilities will set the pickedTab value to the index of the picket value inside the tabBarTitles array.

News Main View

New Tabs can easily be added by simply adding a value to the tabBarTitles.

@State var pickedTab = 0
var tabBarTitles: [String] = ["News".localize(), "Events".localize()]

private var pickerView: some View {
  Picker(selection: self.$pickedTab, label: Text("Tab")) {
    ForEach(0..<tabBarTitles.count) { index in
      Text(self.tabBarTitles[index]).tag(index)
    }
  }
    .pickerStyle(SegmentedPickerStyle())
    .padding()
}

Based on the pickedValue var, the switchTabView will either return the list of news or the list of events. This happens via a binding so the switchTabView will react to the button change instantly and will display the correct list.

private var switchTabView: AnyView {
  switch self.pickedTab {
  case 0:
    return loadableViewNews.toAnyView()
  case 1:
    return loadableViewEvents.toAnyView()
  default:
    return EmptyView().toAnyView()
  }
}

Listing News and Events

Both the mentioned news list as well as the events list follow the same structure. They are implemented as LoadableView consisting of a List of items. The LoadableView is defined in the General Section of the project. It has different states in form of an enum. The state can be set to loading to indicate that the LoadableView is currently Loading its items. This is shown accordingly to the user. When the items are loaded, the actual content of the LoadableView is presented. In this case, it presents either the list of news or the list of events. Both lists have a pullToRefresh view modifier set, which will either re-request the news or events and overwrite the currently loaded items. List for news:

private var loadableViewNews: some View {
  LoadableView(loadable: vm.news) { news in
    List(news) { newsItem in
      self.createNewsNavigationLink(newsItem: newsItem)
        .onAppear {
          self.vm.loadMoreNewsIfNeeded(currentNewsItem: newsItem)
        }
    }
      .pullToRefresh(isShowing: self.$isRefreshing) {
          self.vm.reloadNews()
          self.isRefreshing = false
      }
      .toAnyView()
  }
}

Elements of the News- and Events- List

These lists consist of NavigationLink s which are defined by a view and a navigation target. The view for this NavigationLink is the either the NewsItemRow or the EventItemRow, depending on the picked value. Accordingly, the navigation target is either the NewsItemDetailView or the EventItemDetailView. The NavigationLink ensures that clicking on one of the Row Views will open the according detail view.

func createNewsNavigationLink(newsItem: NewsItem) -> NavigationLink<NewsItemRow, NewsItemDetailView> {
  // use same view model for Row and DetailView to ensure consistency between them
  let newsItemViewModel = NewsItemViewModel(newsItem: newsItem, appState: self.appState)
  let newsItemDetailView = NewsItemDetailView(vm: newsItemViewModel)
  let label = { NewsItemRow(vm: newsItemViewModel) }
  let link: NavigationLink<NewsItemRow, NewsItemDetailView> = NavigationLink(destination: newsItemDetailView, label: label)
  return link
}

Both the Row and the DetailView use the same viewmodel. For details on why this is the case, see the documentation about the Detail Views. As soon as the body is loaded, a number of News and Events is loaded from the backend. This is ensured by the onAppear view modifier of the NavigationView.

Loading and Handling of News and Events Data

The loading of news and events is handled within the viewmodel of the NewsEventsMainView. It is implemented as an extension to the NewsEventsMainView and can by found under “viewModels”. This viewModel holds two arrays containing the loaded news and events and is connected to the NewsWebService and the EventsWebService. For loading the news and events from the backend, the viewmodel implements different methods which call the web services and store the loaded data inside the provided arrays. For both news and events, there are 3 different methods: a reload method, a loadMoreIfNeeded method and a loadMore method. The reload method is called when the NewsEventsMainView is initialised and loads either the most up-to-date news or the events that are the first to come in the future. the loadMoreIfNeeded methods check if more items have to be loaded depending on the scroll position of the user. If he scrolls towards the end of the list, a the loadMore method is called which will load more items from the backend. The loadMore method for the news loads a number of news items older than the already loaded ones. For this purpose, the timestamp of the last news item in the list is included in the request (the “oldest” one). For the events, the loadMore method loads a number of events happening after the last one in the list (these events are further into the future).

The loaded news and events are stored within the news and events arrays which are published to the NewsEventsMainView. This is handled by Swift with the @Published annotation:.

extension NewsEventsMainView {
  class ViewModel: ObservableObject {
    @Published private(set) var news: Loadable<[NewsItem]> = .notRequested
    @Published private(set) var events: Loadable<[EventItem]> = .notRequested

    reloadNews() {...}
    loadMoreNewsIfNeeded() {...}
    ...
  }
}

The arrays are defined as Loadable as they are loaded into the LoadbleView.

The NewsEventsMainView observes these changes and will reload the view as soon as the viewmodel’s arrays change. This ensures that the view is up-to-date with the data hold by the viewmodel. Swift handles this by observing the viewmodel:

@ObservedObject var vm: ViewModel

Rows - View for List Elements

The NewsItemRow and the EventItemRow consist of a view and a viewmodel. Their views are shown inside the news list or event list and display the item of the list. The viewmodel for a item row holds either a news item or an event item. It is observed by the corresponding view.

struct NewsItemRow: View {
  @ObservedObject var vm: NewsItemViewModel
  ...
}

class NewsItemViewModel: ObservableObject {
  @Published var newsItem: NewsItem
  ...
}

The actual view only consists of view elements displaying the information held in the view model’s item. Additionally, the view also includes a ContextMenu containing the functionality provided for news- and event items.

News Context Menu

For each functionality, the contextMenu holds one button. For news, the functionalities are Like, Share and Open in Safari. Events have Set Interest, Share, Open in Safari and Add to Calendar. The Like and the Set Interest are basically the same. The buttons inside the contextMenu holding these functionalities trigger methods inside the view model in form of intents. The viewmodel also holds a reference to a web service (either NewsWebService or EventsWebService) to provide the actual functionality.

The only exception is the Share functionality. It does not trigger an intent towards the viewmodel. Instead, it triggers a ShareSheet to be shown to the user.

Share Sheet
@State private var showShareSheet = false

private var contextMenu: some View {
  ...
  Button(action: {self.showShareSheet = true}, label: {
    Text("Share".localize())
    Image("share")
      .resizable()
      .frame(width: 20.0, height: 20.0)
      .foregroundColor(.lightGray)
  })
  .sheet(isPresented: $showShareSheet) {
    ShareSheet(activityItems: ["Check out this article.".localize() + "\n\(self.vm.newsItem.link ?? "not available".localize())"])
  }
  ...
}

This is needed since SwiftUI does not yet provide a smooth way to implement a share functionality. However, UIKit does provide this functionality. The ShareSheet used here is a simple wrapper for the UIKit Share functionality for SwiftUI. It uses a UIActivityViewController to show the ShareSheet and provide the share functionality. When the Share button is pressed, the showShareSheet boolean is simply set to true. The .sheet view modifier has a binding to this variable and will trigger the showing of the ShareSheet Dismissing the ShareSheet is automatically handled by the ShareSheet itself.

As the Add to Calendar functionality accesses the calendar application, it is necessary to request permission for the access. If permission is granted, the calendar can be accessed and an event is created.

let eventStore: EKEventStore = EKEventStore()
eventStore.requestAccess(to: .event) { (granted, error) in
  if granted && error == nil {
    // access to event store is granted and no error is present
    // prepare event
    let event: EKEvent = EKEvent(eventStore: eventStore)
    event.title =
          ...

    do {
        // try to save the event to the event store
        try eventStore.save(event, span: .thisEvent)
    } catch {
        // show error
        self.showAlert(title: "Unable to save event to calendar".localize())
    }
    self.showAlert(title: "Events".localize(),
    message: "Event was successfully added to your calendar".localize())
  } else {
    // access rights for event store were not granted. Show error.
    self.showAlert(title: "Unable to save event to calendar".localize())
  }
}

Detailed Views for News and Events

News Detail View Events Detail View

The NewsItemDetailView and the EventItemDetailView provide detailed information about the corresponding News or Event. They consist of a Scrollview, which holds all view elements presenting the information. Additionally, they also hold buttons at the end of the scrollview providing the same functionality as the context menu. Since they share the exact same functionality as the context menu (which is included inside the Row views), the use the same viewmodel as the corresponding Row. This also ensures that liking inside the detail view will also update the row inside the list and vice versa.

Presenting Success and Failure

After performing one of the possible user actions, the view model tells the view to display an error or success message.

Share Sheet Share Sheet

For this, the viewmodel publishes a value called showAlert and holds two accessible Strings: the alertTitle and the alertMessage When the showAlert Bool is set to true, the view will get notified and will display an error containing the alertTitle and the alertMessage of the viewmodel.

struct NewsItemRow: View {
  @ObservedObject var vm: ViewModel

  var body: some View {
    HStack(alignment: .center) {
      ...
    }
      .alert(isPresented: Binding<Bool>(
        get: { self.vm.showingAlert },
        set: { _ in self.vm.showingAlert = false }
      )) {
        Alert(title: Text(self.vm.alertTitle),
          message: Text(self.vm.alertMessage ?? ""),
          dismissButton: .default(Text("OK")))
      }
  }
}

extension EventItemRow {
  class ViewModel: ObservableObject {
    @Published var showingAlert = false
    var alertTitle: String = ""
    var alertMessage: String?

    ...

    func showAlert(title: String, message: String? = nil) {
      alertTitle = title
      alertMessage = message
      DispatchQueue.main.async {
        self.showingAlert = true
      }
    }
  }
}

More

The more tab is the place where the user can find all the other features of the app. The user has the ability to change which features are located at the bottom. The rest is located in the more tab.

More Tab

Bulletin Board

The Bulletin Board is a feature that allows users to post adverts. The Bulletin Board is accessible from the More tab. It is a list of adverts, which can be filtered by category. The adverts can be sorted by date or by distance. The user can also create a new advert or edit an existing one.

Bulletin Board Requests

The Bulletin Board is implemented as a TabView.

TabView {
  BulletinBoardView()
    .tabItem {
      Image("bulletin_board")
      Text("Bulletin Board".localize())
    }
  ...
}

The BulletinBoardRow is a View that displays the title, the category and the date of the advert.

Bulletin Board Offers

The BulletinBoardView is implemented as a View.

Bulletin Board Detail View

The BulletinBoardDetailView is implemented as a View. It displays the title, the category, the date, the description and the contact information of the advert.

Advertisement Creation

The BulletinBoardEditView is implemented as a View. It allows the user to create a new advert or edit an existing one. The user can enter the title, the category, the description and the contact information of the advert.

My Advertisements

The MyAdvertisementsView is implemented as a View. It displays all the adverts created by the user. The user can edit or delete an advert.

Feedback

The Feedback feature allows users to send feedback to the app developers. The Feedback feature is accessible from the More tab. It is a form that allows the user to enter the feedback and the contact information.

Feedback

The FeedbackView is implemented as a View. It displays a form that allows the user to enter the feedback and the contact information.

Public Transit

The Public Transit feature allows users to find the next public transit at the nearest stop or their favorite stop.

ToDo

Some features are still in development and will be added in the future.

  • [ ] Map

  • [ ] Cantine

Map

The Map feature will allow the user to see the location of the different buildings on campus. It can also help navigate the user to a specific building.

Map

Cantine

The cantine feature will allow the user to see the menu of the cantine.

Testflight Release

This is a step-by-step tutorial on how to distribute your app on TestFlight. If you have problems, please look at https://www.ralfebert.de/ios/testflight-tutorial/ or similar pages.

The following will describe the necessary steps to publish an app version in TestFlight. Testflight is a tool for testing beta versions of apps and is useful for collecting feedback before releasing an application in the App Store.

Before you start

Log In

Before you start uploading your newest release, you have to log in to your developer account. Therefore, use the credentials the project owner will hand out to you. On the menu bar click on XCode > Preferences and navigate to Account. Then log in using the given credentials.

Note

Due to 2FA it may happen that you have to provide a 6-digit number that is sent to the main-device of the product owner. If so, please contact the product owner.

Organise upload

For uploading your newest release, make sure your branch is up to date and that you are on the dev branch. Also ensure that your current version is working and not crashing or lagging on tests.

Upload new version

Create archive

In order to create an archive, first check that your app can be run on any iOS device. Therefore choose “Any iOS device” in the Simulator Settings.

Select "Any iOS device" in the Simulator Settings on top of the program window

Afterwards, navigate to Product > Archive in the top bar. After a moment the archive should be build successfully and the Organizer window shows up. If not, you can open it manually via Window > Organizer.

Distribute app

In the Organizer the latest archives are listed. Choose the latest created version for your release. Then click on Distribute App.

../_images/1.png

Afterwards you have to choose the method of distribution. Here, select AppStore Connect.

../_images/2.png

Next, you have to choose a destination. Here, check Upload.

../_images/3.png

Now you can select special options for you App Store Connect distribution. Make sure, all three options are checked.

../_images/4.png

Finally, the application asks to re-sign the release version for your App Store Connect distribution. Make sure, that your project is automatically signed.

../_images/5.png

Now you can upload your version to App Store Connect.

../_images/6.png

Lastly, log in to the Apple App Store Connect account (2FA may be needed) and make sure the latest version was successfully uploaded. After publishing it, you can add the development team for testing.