lastminute.com logo

Technology

Widget for our iOS app using SwiftUI: the Technical Implementation

fabrizio_duroni
fabrizio duroni
marco_de_lucchi
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

Widget
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: Alignment.center) {
        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 {
    Color.clear.overlay(
      Image(uiImage: image)
        .resizable()
        .aspectRatio(contentMode: ContentMode.fill)
        .overlay(
            LinearGradient(
                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 {
    ZStack{
      BackgroundImage(
        image: UIImage(named: configurationImageName)!,
        overlay: LinearGradient(gradient: Gradient(colors: [Color("ShadowColor")]),
                                startPoint: .top, endPoint: .bottom)
      )
      Group {
        Spacer()
        HStack(alignment: VerticalAlignment.center) {
          Spacer()
          Text(LocalizedStringKey("widget.inspirational.configure"))
            .foregroundColor(Color("TextColor"))
            .font(Font.custom("Ubuntu-Regular", size: 16))
            .multilineTextAlignment(TextAlignment.center)
          Spacer()
        }
        Spacer()

      }
    }
  }
}

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) {
    completion(FakeDealsEntryProvider.shared.get())
  }

  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)
        completion(timeline)
      }
    } 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
      DealEntry(
        date: Calendar.current.date(byAdding: .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 URLSession.shared.data(from: 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 {
  constructor(
    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, this.link);
  }
}

export class WidgetsNavigatorHelper {
  //... other code

  async navigate(navigation: any, url: string): Promise<void> {
    //... other code
    this.navigation.openSearchScreenUsing(parseParamsFrom(url));
  }
}

Conclusion

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

Impacts of contract tests in our microservice architecture

Impacts of contract tests in our microservice architecture

ivan_delloro
ivan delloro

We learned how to implement contract test for any kind of messages exchanged in a microservices' architecture, now let's see the impacts and why it sped up our development process [...]

Widget iOS: how to fix the image bundling problem

Widget iOS: how to fix the image bundling problem

fabrizio_duroni
fabrizio duroni
marco_de_lucchi
marco de lucchi

Discover how we had a long day debugging WidgetKit problems with images. [...]