Create a List Report Floorplan
Prerequisites
- Tutorials: Get a Free Trial Account on SAP BTP and Set Up the SAP BTP SDK for iOS
- Development environment: Apple Mac running macOS Catalina or higher with Xcode 11 or higher
- SAP BTP SDK for iOS: Version 5.0
- How to use the
SAPFiori
UI controls to build a List Report Floorplan
- Step 1
In this example, you build upon the Tutorial app created using the Sample OData service. If you examine the service’s metadata you can see entity Supplier has a one-to-many relationship with Products:
XMLCopy<EntityType Name="Supplier"> <Key> <PropertyRef Name="SupplierId"/> </Key> <Property MaxLength="40" Name="City" Nullable="true" Type="Edm.String"/> <Property MaxLength="3" Name="Country" Nullable="true" Type="Edm.String"/> <Property MaxLength="255" Name="EmailAddress" Nullable="true" Type="Edm.String"/> <Property MaxLength="10" Name="HouseNumber" Nullable="true" Type="Edm.String"/> <Property MaxLength="30" Name="PhoneNumber" Nullable="true" Type="Edm.String"/> <Property MaxLength="10" Name="PostalCode" Nullable="true" Type="Edm.String"/> <Property MaxLength="60" Name="Street" Nullable="true" Type="Edm.String"/> <Property MaxLength="10" Name="SupplierId" Nullable="false" Type="Edm.String"/> <Property MaxLength="80" Name="SupplierName" Nullable="true" Type="Edm.String"/> <Property Name="UpdatedTimestamp" Type="Edm.DateTime"/> <NavigationProperty FromRole="Supplier" Name="Products" Relationship="ESPM.Supplier_Product_One_Many0" ToRole="Product"/> </EntityType>
The app you’ve generated with the SAP BTP SDK Assistant for iOS (Assistant) has currently its UI. You’re going to make that app your own now.
Open the
Main.storyboard
, select all displayed View Controllers and delete them.Add a new
UITableViewController
to the storyboard and embed it in aUINavigationController
.Now change the
UINavigationController
to be the Initial View Controller.To let the app load your newly added screen it is necessary to change the application screen code in the
ApplicationUIManager.swift
.Open the
ApplicationUIManager.swift
class and locate theshowApplicationScreen(completionHandler:)
method.Replace the following code inside the
showApplicationScreen(completionHandler:)
method:SwiftCopylet appDelegate = (UIApplication.shared.delegate as! AppDelegate) let splitViewController = UIStoryboard(name: "Main", bundle: Bundle.main).instantiateViewController(withIdentifier: "MainSplitViewController") as! UISplitViewController splitViewController.delegate = appDelegate splitViewController.modalPresentationStyle = .currentContext splitViewController.preferredDisplayMode = .allVisible appViewController = splitViewController
with:
SwiftCopylet navigationViewController = UIStoryboard(name: "Main", bundle: Bundle.main).instantiateInitialViewController() as! UINavigationController appViewController = navigationViewController
Instead of instantiating the
MainSplitViewController
theUIStoryboard
will instantiate the initial View Controller and cast it to theUINavigationController
.Lastly, you have to create a new class inheriting from
UITableViewController
. Create a new Cocoa Touch Class and name itSupplierTableViewController
.Set the created class as Custom Class in the storyboard for the added
UITableViewController
. - Step 2
Now that you have the first
UITableViewController
setup you will add code to load and display suppliers in aUITableView
.Open the
SupplierTableViewController.swift
class and add the following import statements for full usage of the SDK:SwiftCopyimport UIKit import SAPOData import SAPFoundation import SAPFiori import SAPFioriFlows import SAPCommon import SAPOfflineOData import ESPMContainerFmwk import SharedFmwk
Next let the
SupplierTableViewController.swift
class conforms to theSAPFioriLoadingIndicator
protocol provided by the Assistant generated code. You can locate the protocol in theUtils
group.SwiftCopyclass SupplierTableViewController: UITableViewController, SAPFioriLoadingIndicator { ... }
Xcode will ask you to fully conform to the protocol by adding a
loadingIndicator
class property. Add the following line of code directly below the class declaration:SwiftCopyvar loadingIndicator: FUILoadingIndicatorView?
Implement the following lines of code directly below the loading indicator property:
SwiftCopy// The available destinations from Mobile Services are hold in the FileConfigurationProvider. Retrieve it to find the correct data service let destinations = FileConfigurationProvider("AppParameters").provideConfiguration().configuration["Destinations"] as! NSDictionary // Retrieve the data service using the destinations dictionary and return it. let destinations = FileConfigurationProvider("AppParameters").provideConfiguration().configuration["Destinations"] as! NSDictionary var dataService: ESPMContainer<OfflineODataProvider>? { guard let odataController = OnboardingSessionManager.shared.onboardingSession?.odataControllers[ODataContainerType.eSPMContainer.description] as? ESPMContainerOfflineODataController, let dataService = odataController.dataService else { AlertHelper.displayAlert(with: "OData service is not reachable, please onboard again.", error: nil, viewController: self) return nil } return dataService }
In case you’re using an
OfflineODataProvider
you have to change the above-mentioned code to useOfflineODataProvider
instead ofOnlineODataProvider
. You have to also importSAPOfflineOData
in addition to theSAPOData
framework.SAP offers a simple-to-use Logging API with the
SAPCommon
framework.Implement the following line of code below the data service declaration:
SwiftCopyprivate let logger = Logger.shared(named: "SupplierTableViewController")
Before you will implement the data loading methods you have to implement a supplier array property to save the loaded data in memory.
Add the following line of code below the logger instantiation:
SwiftCopyprivate var suppliers = [Supplier]()
From now on bigger code blocks are explained with inline comments. Read the inline comments carefully to fully understand what the code is doing and why you’re implementing it.
Loading all available suppliers is fairly easy using the generated data service. The generated code will handle all authentication and authorization challenges for you and the data service will construct all necessary requests to load, create and update entities in your backend.
Implement the following methods directly under the closing bracket of the
viewDidLoad()
method:SwiftCopy// MARK: - Data loading /// When this method gets called to show a loading indicator. When the completion handler of the loadData(:) method gets executed hide the loading indicator. private func updateTableView() { self.showFioriLoadingIndicator() loadData { self.hideFioriLoadingIndicator() } } /// Load the suppliers by using the fetchSuppliers() method provided by the data service. private func loadData(completionHandler: @escaping () -> Void) { // fetch products dataService?.fetchSuppliers() { [weak self] suppliers, error in // handle errors if let error = error { self?.logger.error("Error while fetching list of suppliers.", error: error) return } // set loaded suppliers to property and reload data on the table view self?.suppliers = suppliers! self?.tableView.reloadData() completionHandler() } }
Call the
updateTableView()
method inside theviewDidLoad()
:SwiftCopyoverride func viewDidLoad() { super.viewDidLoad() updateTableView() }
Congratulations, your app is fetching data now.
- Step 3
Using the
UITableViewController
makes it easy for a developer to display data in a list.First, implement two necessary
UITableViewDataSource
methods which are responsible for telling theUITableView
how many sections and rows to display. Implement those two methods directly below theloadData(:)
method:SwiftCopy// MARK: - Table view data source /// Only one section is displayed for this screen, return 1. override func numberOfSections(in tableView: UITableView) -> Int { return 1 } /// Return the count of the suppliers array. override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return suppliers.count }
Using the
SAPFiori
framework allows you to choose from a large variety ofUITableViewCell
classes.
Because you’re going to display suppliers, and those have a name, an address and probably contact data theFUIContactCell
would be a perfect fit here. You can always use the SAP Fiori Mentor app, available in the App Store for iPad, to get an introduction to the control.Before implementing the
tableView(_cellForRowAt:)
method responsible for dequeuing reusable cells and returning them to theUITableView
, You need to register theFUIContactCell
with theUITableView
first. This is usually done in theviewDidLoad()
method.Implement the following lines of code before the
updateTableView()
method call in theviewDidLoad()
:SwiftCopy// Register the cell with the provided convenience reuse identifier. tableView.register(FUIContactCell.self, forCellReuseIdentifier: FUIContactCell.reuseIdentifier) // Set the separator style of the table view to none and the background colour to the standard Fiori background base colour. tableView.separatorStyle = .none tableView.backgroundColor = .preferredFioriColor(forStyle: .backgroundBase)
It is time to implement the
tableView(_cellForRowAt:)
method to dequeue theFUIContactCell
:SwiftCopyoverride func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // Get the specific supplier for the current row. let supplier = suppliers[indexPath.row] // Dequeue the FUIContactCell using the convenience reuse identifier. Force cast it to the FUIContactCell let cell = tableView.dequeueReusableCell(withIdentifier: FUIContactCell.reuseIdentifier) as! FUIContactCell // Set values to the cell's properties. cell.headlineText = supplier.supplierName ?? "No Name available!" cell.subheadlineText = "\(supplier.street ?? "") \(supplier.houseNumber ?? "") \(supplier.city ?? ""), \(supplier.postalCode ?? "") \(supplier.country ?? "")" //Navigation will be implemented later. This cell has the disclosure indicator as accessory type indicating that navigation to the user. cell.accessoryType = .disclosureIndicator return cell }
You could run the app now and should see a list of suppliers getting loaded and displayed.
- Step 4
Using the
FUIContactCell
allows us to use anFUIActivityControl
inside of the cell to let the user contact a supplier. TheFUIActivityControl
documentation explains the control and its variations in more detail.First, you have to define which so-called
FUIActivityItems
you want to use in theFUIActivityControl
.Add the following line of code below the supplier’s array:
SwiftCopy/// Define the contact possibilities for the user: messaging, phone call, and email private let activities = [FUIActivityItem.message, FUIActivityItem.phone, FUIActivityItem.email]
With that, you can simply add those activities to the
FUIContactCell
. This specific cell carries theFUIActivityControl
in its belly in is accessible through the available public API.Go back to the
tableView(_cellForRowAt:)
method and add the following lines of code directly above the assignment of the accessory type:SwiftCopycell.activityControl.addActivities(activities) cell.activityControl.maxVisibleItems = 3 // The FUIActivityControl provides you two different ways of reacting to user's interaction. One would be with a change handler the other would be with a delegate. Because I don't want the communication logic being in the tableView(_cellForRowAt:) method you're using the delegation way. cell.activityControl.delegate = self
To properly react to the user’s interaction with the control you will implement an extension conforming to the
FUIActivityControlDelegate
.Implement the extension:
SwiftCopyextension SupplierTableViewController: FUIActivityControlDelegate { func activityControl(_ activityControl: FUIActivityControl, didSelectActivity activityItem: FUIActivityItem) { // Use a Switch to check for the identifier and act accordingly. switch activityItem.identifier { case FUIActivityItem.message.identifier: AlertHelper.displayAlert(with: "Messaging supplier!", error: nil, viewController: self) break case FUIActivityItem.phone.identifier: AlertHelper.displayAlert(with: "Calling supplier!", error: nil, viewController: self) break case FUIActivityItem.email.identifier: AlertHelper.displayAlert(with: "Send email to supplier!", error: nil, viewController: self) break default: return } } }
In this tutorial, you will not implement the actual code for doing the communication. If you’re interested in how to do so, you can use Apple’s official documentation:
If you run the app now you should see the following screen:
Tapping on one of the
FUIActivityItem
will result in an alert dialogue showing up. - Step 5
In this step, you will implement a second
UITableViewController
displaying all products a supplier provides.
For this, you will use a storyboard segue to navigate to theSupplierProductsTableViewController
and pass through the selected supplier.In case you’re not familiar with segues please visit, and carefully read the official documentation before continuing. Using Segues
Open up the
Main.storyboard
and add a newUITableViewController
from the Object Library directly next to theSupplierTableViewController
. Create a new segue from one of the prototype cells inside of theSupplierTableViewController
to the newly addedUITableViewController
.Select the segue and open the Identity Inspector to set the Identifier to
showSupplierProducts
.Go back to the
SupplierTableViewController
and add a new class constant of type String which will hold the segue identifier.SwiftCopyprivate let segueIdentifier = "showSupplierProducts"
Create a new Cocoa Touch class with the name
SupplierProductsTableViewController
.Set the Custom Class of the newly added
UITableViewController
toSupplierProductsTableViewController
in theMain.storyboard
.Open the
SupplierProductsTableViewController
and add a class property that will contain the selected supplier.SwiftCopyvar supplier: Supplier!
Next, you will implement the
prepare(:for:sender:)
method which is responsible for making necessary preparations before the navigation is fully executed. In our case, you will pass the selected supplier to theSupplierProductsTableViewController
.Implement the
prepare(:for:sender:)
method inSupplierTableViewController
:SwiftCopy// MARK: - Navigation override func prepare(for segue: UIStoryboardSegue, sender: Any?) { // Check for the correct identifier if segue.identifier == segueIdentifier { // Get the selected row from the table view. if let indexPathForSelectedRow = tableView.indexPathForSelectedRow { // Get the destination view controller and cast it to SupplierProductsTableViewController. let destinationVC = segue.destination as! SupplierProductsTableViewController // Set the selected supplier. destinationVC.supplier = suppliers[indexPathForSelectedRow.row] } } }
You can utilise the
tableView(_:didSelectRowAt:)
method to trigger the navigation. Implement the override method:SwiftCopyoverride func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { performSegue(withIdentifier: segueIdentifier, sender: tableView) }
You can now navigate back and forth between the
SupplierTableViewController
and theSupplierProductsTableViewController
. - Step 6
This view is similar to the
SupplierTableViewController
but instead of fetching all products, you will fetch supplier-specific products. To achieve that, you again can utilise the OData APIs.SAPOData
provides the possibility to create so-calledDataQuery
objects which can define typical OData arguments for a backend call.First, you need to make the needed import statements for that class:
SwiftCopyimport SAPOData import SAPFoundation import SAPFiori import SAPFioriFlows import SAPCommon import ESPMContainerFmwk import SharedFmwk import SAPOfflineOData
Let’s use the logger again. Add the following line of code below the supplier property:
SwiftCopyprivate let logger = Logger.shared(named: "SupplierProductsViewController")
Add a couple of class properties necessary for the data service instance and the fetched products. Implement the following lines of code:
SwiftCopy// The available destinations from Mobile Services are hold in the FileConfigurationProvider. Retrieve it to find the correct data service let destinations = FileConfigurationProvider("AppParameters").provideConfiguration().configuration["Destinations"] as! NSDictionary var dataService: ESPMContainer<OfflineODataProvider>? { guard let odataController = OnboardingSessionManager.shared.onboardingSession?.odataControllers[ODataContainerType.eSPMContainer.description] as? ESPMContainerOfflineODataController, let dataService = odataController.dataService else { AlertHelper.displayAlert(with: "OData service is not reachable, please onboard again.", error: nil, viewController: self) return nil } return dataService } private var products = [Product]()
Also, you want to utilise the provided loading indicator. Let the class conform to the
SAPFioriLoadingIndicator
protocol.SwiftCopyclass SupplierProductsTableViewController: UITableViewController, SAPFioriLoadingIndicator { ... }
Of course, don’t forget the loading indicator class property:
SwiftCopyvar loadingIndicator: FUILoadingIndicatorView?
Let’s load some data!
You’re using the same style you’ve used in the
SupplierTableViewController
. Implement the following two methods and read the inline comments carefully because you will see that you utilise theDataQuery
object for making a filter as well as an expand.If you’re not familiar with those OData specific terms please make yourself familiar with the OData specification:
SwiftCopy
// You know that one u{1F609} private func updateTableView() { self.showFioriLoadingIndicator() loadData { self.hideFioriLoadingIndicator() } } private func loadData(completionHandler: @escaping () -> Void) { // Retrieve the supplier id let supplierID = supplier.supplierID! // Create a new DataQuery object applying a filter and an expand. The filter will filter on the specific // supplier id and the expand will construct the URL in a way that the backend returns the stock details // for each product. let dataQuery = DataQuery().filter(Product.supplierID == supplierID).expand(Product.stockDetails) dataService?.fetchProducts(matching: dataQuery) { [weak self] products, error in // handle errors if let error = error { self?.logger.error("Error while fetching list of products for supplier: \(supplierID).", error: error) return } // set loaded products to property and reload data on the table view self?.products = products! self?.tableView.reloadData() completionHandler() } }Call the
updateTableView()
method as last statement in theviewDidLoad()
.SwiftCopyoverride func viewDidLoad() { super.viewDidLoad() updateTableView() }
Products contain images for each product, it would be nice to display them as well, but to do so you have to write a little bit of code to make that happen.
First, implement a class property holding the image URLs of all products.
SwiftCopyprivate var productImageURLs = [String]()
The user might want to scroll through the products even if the images are not fully loaded yet you have to implement a simple image cache as well as a placeholder image to keep the performance of the table stable.
Add the following line of code directly below the
productImageURLs
:SwiftCopy// This simple dictionary will contain all the fetched images in memory. private var imageCache = [String: UIImage]()
Now implement a method responsible for fetching the product images and caching them. Add the following method right below the
loadData(:)
method:SwiftCopy
/// This method will take a URL and an escaping completion handler as arguments. The URL is the backend URL for your deployed sample service. private func loadProductImageFrom(_ url: URL, completionHandler: @escaping (_ image: UIImage) -> Void) { // Retrieve the SAP URLSession from the onboarding session. let appDelegate = UIApplication.shared.delegate as! AppDelegate if let sapURLSession = appDelegate.sessionManager.onboardingSession?.sapURLSession { // Create a data task, this is the same as the URLSession data task. sapURLSession.dataTask(with: url, completionHandler: { data, _, error in // Handle errors if let error = error { self.logger.error("Failed to load image!", error: error) return } // Instantiate an image from data. if let image = UIImage(data: data!) { // safe image in image cache self.imageCache[url.absoluteString] = image // Dispatch back to the main queue. DispatchQueue.main.async { completionHandler(image) } } }).resume() } }Now you have the foundation for fetching and caching images. You were probably wondering where the mapping from the fetched products to the product image URLs happens. You will implement that now.
Go back to the
loadData(:)
method and add the following line of code directly below the product assignment. YourloadData(:)
should look like this now:SwiftCopyprivate func loadData(completionHandler: @escaping () -> Void) { let supplierID = supplier.supplierID! let dataQuery = DataQuery().filter(Product.supplierID == supplierID).expand(Product.stockDetails) dataService?.fetchProducts(matching: dataQuery) { [weak self] products, error in // handle errors if let error = error { self?.logger.error("Error while fetching list of products for supplier: \(supplierID).", error: error) return } // set loaded products to property and reload data on the table view self?.products = products! // Use .map to create an array of picture URLs self?.productImageURLs = products!.map { $0.pictureUrl ?? "" } self?.tableView.reloadData() completionHandler() } }
Like the last time you have to register a
SAPFiori
cell with theUITableView
, but this time it is aFUIObjectTableViewCell
. Add the following lines of code to theviewDidLoad()
method right before theupdateTableView(:)
method call.SwiftCopytableView.register(FUIObjectTableViewCell.self, forCellReuseIdentifier: FUIObjectTableViewCell.reuseIdentifier) tableView.separatorStyle = .none tableView.backgroundColor = .preferredFioriColor(forStyle: .backgroundBase)
As the last step, you have to implement the table views data source methods similar to the
SupplierTableViewController
.Add the following methods directly below the
loadProductImageFrom(_:completionHandler:)
method and make sure to modify the value ofbaseURL
SwiftCopy// MARK: - Table view data source override func numberOfSections(in tableView: UITableView) -> Int { return 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return products.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let product = products[indexPath.row] let cell = tableView.dequeueReusableCell(withIdentifier: FUIObjectTableViewCell.reuseIdentifier) as! FUIObjectTableViewCell cell.headlineText = product.name ?? "" cell.subheadlineText = product.categoryName ?? "" cell.footnoteText = product.productID ?? "" cell.statusText = "Min Stock: \(product.stockDetails?.minStock?.doubleValue() ?? 0.0)" // set a placeholder image cell.detailImageView.image = FUIIconLibrary.system.imageLibrary // This URL is found in Mobile Services API tab let baseURL = "Your API found in the Sample Service assigned to your mobile app configuration in MS" let url = URL(string: baseURL.appending(productImageURLs[indexPath.row])) guard let unwrapped = url else { logger.info("URL for product image is nil. Returning cell without image.") return cell } // check if the image is already in the cache if let img = imageCache[unwrapped.absoluteString] { cell.detailImageView.image = img } else { // The image is not cached yet, so download it. loadProductImageFrom(unwrapped) { image in cell.detailImageView.image = image } } return cell } }
Run the app and navigate to the
SupplierProductsTableViewController
. - Step 7
Wouldn’t it be cool to also have a
FUISearchBar
which is inheriting fromUISearchBar
? - Of course, it would be, so let’s implement that.In case you’re not familiar with the
UISearchBar
orUISearchController
read the official documentation:Open the
SupplierProductsTableViewController.swift
class and add the following two class properties right below the products array:SwiftCopyprivate var searchController: FUISearchController? // Will hold the searched for products in that array. private var searchedProducts = [Product]()
Let’s implement a bit of setup logic for the
FUISearchBar
. Add the following method to your class:SwiftCopyprivate func setupSearchBar() { // Search Controller setup searchController = FUISearchController(searchResultsController: nil) // The SupplierProductsViewController will take care of updating the search results. searchController!.searchResultsUpdater = self searchController!.hidesNavigationBarDuringPresentation = true searchController!.searchBar.placeholderText = "Search for products" // No Barcode scanner needed here searchController!.searchBar.isBarcodeScannerEnabled = false self.tableView.tableHeaderView = searchController!.searchBar }
Xcode will complain now because the
SupplierProductsTableViewController.swift
class is not conforming to theUISearchResultsUpdating
protocol.Add an extension to your class, like you did in the
SupplierTableViewController
:SwiftCopyextension SupplierProductsTableViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { // to implement } }
Call the
setupSearchBar()
method inside theviewDidLoad()
:SwiftCopyoverride func viewDidLoad() { super.viewDidLoad() setupSearchBar() updateTableView() }
If you run the app now you should see the
FUISearchBar
being displayed above theUITableView
.Now you have to implement some search logic to be called in the
updateSearchResults(for:)
method.Implement the following methods right below the
setupSearchBar()
method and carefully read the inline comments.SwiftCopy// verify if the search text is empty or not private func searchTextIsEmpty() -> Bool { return searchController?.searchBar.text?.isEmpty ?? true } // actual search logic for finding the correct products for the term the user is searching for private func searchProducts(_ searchText: String) { searchedProducts = products.filter({( product : Product) -> Bool in return product.name?.lowercased().contains(searchText.lowercased()) ?? false }) tableView.reloadData() } // verify if the user is currently searching or not private func isSearching() -> Bool { return searchController?.isActive ?? false && !searchTextIsEmpty() }
Cool! Let’s implement the
updateSearchResults(for:)
method:SwiftCopyextension SupplierProductsTableViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { // Get the searched-for term, note here that you don't have a time bouncer which waits for the user to finish its input. You could implement that if needed, for this simple example you do life searches for each character. I wouldn't recommend doing that over a large data set. if let searchText = searchController.searchBar.text { // Feed it to the search logic. searchProducts(searchText) return } } }
As our last task here is to change the
UITableView
data source methods to use react to potential searches by the user.Change the
tableView(_:numberOfRowsInSection:)
method to:SwiftCopyoverride func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { // if the user is searching display the searched for products and all products otherwise. return isSearching() ? searchedProducts.count : products.count }
Also change the
tableView(_:cellForRowAt:)
method to retrieve the product either from the product array or in case of a search from thesearchedProducts
array.Change the following line of code:
SwiftCopylet product = products[indexPath.count]
to
SwiftCopylet product = isSearching() ? searchedProducts[indexPath.row] : products[indexPath.row]
If you compile and run the app now you should see that you can search for products.
What protocol do you have to conform to, to handle search results? Check the correct answer.
- Make the Generated App Your Own
- Fetch All Available Suppliers From the Sample Service
- Implement the Table View to display a supplier list
- Add an FUIActivityControl to your FUIContactCell
- Implement the Navigation Between the Supplier List and the Product List
- Implement the loading and displaying of supplier-specific products
- Implement the FUISearchBar to search for certain products in the list