Widget for our iOS app using SwiftUI: the Technical Implementation

fabrizio duroni
marco de lucchi

Join us in the Widget creation journey from drawing the UI, creating the timeline, calling the API and adapting the response, setup and reading the user configuration, and finally invoking the React Native app to display the search results.

In the previous post, we presented our FriYaY project: an inspirational iOS widget. We showed the process from start to finish, now it’s time to go deep into the technical details.
Join us in the journey from drawing the UI, creating the timeline, calling the API and adapting the response, setup and reading the user configuration, and finally invoking the React Native app to display the search results.

UI with SwiftUI

Just to recap, we went with a design that shows a single deal using all the space of the widget itself. This means:

  • putting the location image as background
  • adding some branding elements above
  • making the entire widget area touchable
  • opening the app via a custom url, that will be used by React Native to load the search results

This is our widget.

The main view is DealCard. It contains BackgroundImage, that displays the image as background on the widget, and a DealOverlay used for the branding messages. These components are displayed in a ZStack in order to show them one above the other.

struct DealCard: View {
  let deal: Deal

  var body: some View {
    Link(destination: deal.url) {
      ZStack(alignment: {
        BackgroundImage(image: deal.image)
        DealOverlay(cityName: deal.cityName)

The BackgroundImage component is really simple: it just contains the image with an overlay to create some shadow effects.

struct BackgroundImage: View {
  let image: UIImage

  var body: some View {
      Image(uiImage: image)
        .aspectRatio(contentMode: ContentMode.fill)
                gradient: Gradient(colors: [Color("ShadowColor"), Color.clear]),
                startPoint: .top, endPoint: .center)

Instead, DealOverlay is a bit more complex. It contains a VStack where we show our logo, and another VStack where we show the destination name and the deal type (eg. flight + hotel).
For the destination city we implemented also a custom background using Shape and Path in order to create a catching effect.

Last but not least, when the user install the widget, we show a default Configure view where we ask the user to configure it to see the deals.

struct Configure: View {
  let configurationImageName: String

  var body: some View {
        image: UIImage(named: configurationImageName)!,
        overlay: LinearGradient(gradient: Gradient(colors: [Color("ShadowColor")]),
                                startPoint: .top, endPoint: .bottom)
      Group {
        HStack(alignment: {
            .font(Font.custom("Ubuntu-Regular", size: 16))


The IntentTimelineProvider

This is the core business logic of the widget. We implemented the protocols required by WidgetKit in order to serve our deal. To do this we put in place a hexagonal architecture, where we abstracted from the technological implementation on network and adapting side.
Below you can find the complete diagram of the widget classes and how they communicate.

Class diagram
The widget and intent extension classes (SwiftUI components not included)

First we started implementing the DealsProvider, that is in charge to display the deals. This class uses two collaborators:

  • FakeDealEntryProvider, it creates a fake deal to display during the setup of the widget
  • DealEntryProvider, it calls our API and actually build the real timeline with the current deal
class DealsProvider: IntentTimelineProvider {
  private let dealsEntryProvider: DealsEntryProvider

  init(dealsEntryProvider: DealsEntryProvider) {
    self.dealsEntryProvider = dealsEntryProvider

  func placeholder(in context: Context) -> DealEntry {
    return FakeDealsEntryProvider.shared.get()

  func getSnapshot(for configuration: InspirationalConfigurationIntent, in context: Context, completion: @escaping (DealEntry) -> Void) {

  func getTimeline(for configuration: InspirationalConfigurationIntent, in context: Context, completion: @escaping (Timeline<DealEntry>) -> Void) {
    if configuration.locationParameter != nil {
      Task {
        let dealsEntry = try await dealsEntryProvider.getUsing(configuration: configuration)
        let timeline = Timeline(entries: dealsEntry, policy: .atEnd)
    } else {
      completion(Timeline(entries: [FakeDealsEntryProvider.shared.get(isConfigured: false)], policy: .atEnd))

The DealsEntryProvider adapts the deal received from the DealsRepository in a DealEntry. It passes the InspirationalConfigurationIntent to the repository. This object contains the user configured values during the installation.

class DealsEntryProvider {
  private let dealsRepository: DealsRepository

  init(dealsRepository: DealsRepository) {
    self.dealsRepository = dealsRepository

  func getUsing(configuration: InspirationalConfigurationIntent) async throws -> [DealEntry] {
    return try await self.dealsRepository.getUsing(configuration: configuration).enumerated().map({ index, deal in
        date: .hour, value: index, to: Date())!,
        deal: deal,
        configurationImageName: nil

The DealsRepository uses the RestClient to get the data from our API. To decide the right url to be called (based on user parameters e.g. the language) it calls the DealsFeedUrlRepository. It then uses the DealsAdapter in order to convert the json representation into our Deal domain object.

class DealsRepository {
  private let restClient: RestClient
  private let dealsAdapter: DealsAdapter
  private let dealsFeedUrlRepository: DealsFeedUrlRepository

  init(restClient: RestClient, dealsAdapter: DealsAdapter, dealsFeedUrlRepository: DealsFeedUrlRepository) {
    self.restClient = restClient
    self.dealsAdapter = dealsAdapter
    self.dealsFeedUrlRepository = dealsFeedUrlRepository

  func getUsing(configuration: InspirationalConfigurationIntent) async throws -> [Deal]  {
    let dealsJson: [DealServiceData] = try await restClient.getData(from: dealsFeedUrlRepository.get())
    let deals = try await dealsAdapter.adapt(dealsServiceData: dealsJson, configuration: configuration)
    return deals

RestClient is our network client implementation. It leverages the new async/await Swift 5.5 feature. So instead of having callbacks/closure, we wrote our network client with a very similar syntax to the one used in other languages (e.g. TypeScript). Anyway this requires (again as in other languages) that all the caller should be async on their own. You can break the chain of asynchronous calls using a Task { ... } object (see DealsProvider above).

class RestClient {

  func getData<T: Decodable>(from url: URL) async throws -> T {
    let (data, _) = try await get(from: url)
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    let dealsJson = try decoder.decode(T.self, from: data)
    return dealsJson

  func getImage(from url: URL) async throws -> UIImage {
    let (data, _) = try await get(from: url)
    return UIImage(data: data)!

  private func get(from url: URL) async throws -> (Data, URLResponse) {
    return try await url)

Intent Configuration

Our customers can customise the widget results, specifically: choose the departure airport, how many travellers, and the preferred dates. From development point of view this is possible using an Intent, as you see from the screenshot below.

  • The locationParameter will handle the departure airport, it has both the checkbox under Dynamic Option enabled, since it will fetch the values from a Remote API
  • The travellersParameter is an ENUM and will contain the number of travellers
  • The datesParameter is also an ENUM, with three options

'Intent example'
'Intent example'

To provide the dynamic options to the intent you have to create an Intent Extension, which are used also for Siri and Shortcuts.
The protocol will be named as <name>IntentHandling, and will call the repository via the same RestClient as before. We also put in place some ad-hoc adapting in other to translate our API response to fit with the iOS-required type.

class IntentHandler: INExtension, InspirationalConfigurationIntentHandling {
  private let suggesterLocationsRepository = SuggesterLocationsRepository(
    suggesterLocationsAdapter: SuggesterLocationsAdapter(),
    suggesterLocationsUrlCreator: SuggesterLocationsUrlCreator()

  func provideLocationParameterOptionsCollection(for intent: InspirationalConfigurationIntent, searchTerm: String?) async throws -> INObjectCollection<Location> {
    let locations: [Location] = try await suggesterLocationsRepository.get(searchTerm: searchTerm)
    return INObjectCollection(items: locations)

  override func handler(for intent: INIntent) -> Any {
    return self

Communication with React Native

To trigger the search on the app side, we used the deep linking method: we created a custom URL schema with parameters, and we attached it to the deal card with a Link SwiftUI component. On tap, the link will go through the AppDelegate and then shared to React Native via RCTLinkingManager.

- (BOOL)application:(UIApplication *)app
            openURL:(NSURL *)url
            options:(NSDictionary *)options {
  //... other code

  return [RCTLinkingManager application:app openURL:url options:options];

On the React Native side, in the main screen, we have a LinkDispatcher collaborator that listens to custom URLs and dispatches the correct actions (for example, navigates to a specific screen).
When a link is coming from the iOS Widget, we will parse the parameter, create a new search and open the result screen.

export class LinkDispatcher {
    private readonly widgetsNavigatorHelper: WidgetsNavigatorHelper //.. other classes
  ) {}

  async dispatchLink(link: string): Promise<void> {
    //... other code

    if (this.isWidgetLink()) {
      await this.dispatchWidgetLink(link);

    //... other code

  private async dispatchWidgetLink(link: string): Promise<void> {
    return this.widgetsNavigatorHelper.navigate(this.navigation,;

export class WidgetsNavigatorHelper {
  //... other code

  async navigate(navigation: any, url: string): Promise<void> {
    //... other code


That’s all for the technical implementation of the widget.
If you want to know more about our FriYaY journey into WidgetKit, follow the links below:

Read next

How living documentation and user stories acceptance tests can bring project documentation benefits?

How living documentation and user stories acceptance tests can bring project documentation benefits?

thiago nascimento figueiredo

In the software development community, it is a known fact that keeping documentation up to date according to the project requirements is something that needs team discipline. In different scenarios, it is possible to take advantage of a TDD/BDD approach and CI/CD pipeline artifacts to reach the goal of keeping software documentation according to the project deliverables. [...]

How to be more in control of my product?

How to be more in control of my product?

luca mor

AKA: how try not to ruin the day of customers! [...]