From itineraries to widgets

The tale of Skyscanner app’s dynamic result page

The tale of Skyscanner app’s dynamic result page

By Zsombor Fuszenecker

The story so far…

Originally, the flight search results page was only about showing flight itineraries. But after releasing a new version of the app with a results list like the one below, we realised it could be improved upon for routes with multiple options from the same provider.

Notice, that the results above are very similar (same carrier, small difference in price, slightly different flight times) and having a list like that with a large number of items on a small screen is not easy to scan through. Our aim is to make comparison simple, so we recently revamped the look of this page to ensure comparison of flight options is as easy as possible to scan through on a small screen.

This is how the reviews looked in the Netherlands

We quickly moved from understanding the problem to researching the viable solutions then iterating on a lot of prototypes. At the end, we think we found the optimal solution: instead of showing a list of almost identical flights for routes that have many direct flights, we grouped the list by airlines.

Image 3
Image 4

With an overview like this, it’s easy to compare direct carriers and their prices in general. We called this extra content item a widget.

Users loved the change in the Netherlands where we first introduced it

Over time, the number of things we wanted to add to the results list has grown. Warning messages, recommendation widgets and sponsored advertisements are only some of the many additional items that can be part of the result list.

Users loved the change in the Netherlands where we first introduced it
Users loved the change in the Netherlands where we first introduced it
There are some advertisements shown on specific routes with specific market setting

Changing the app’s code every time we added new types of content was not trivial. There was one huge file with many conditionals and edge case handling logic. We also wanted to experiment, which meant our data source got even more full of conditionals. When creating a new widget, the developer had to go through hundreds of lines of If statements. Everything was easily broken and other teams could not contribute without our full support.

So after realising we can’t iterate fast enough on the results list, we started planning the new architecture behind this.

Design goals:

  • Minimise the risk that creating a new widget breaks another one
  • Make experimentation easy; adding a new widget should not be hard and should not the need full support of the team behind the list.
  • Make it possible to run widget calculation codes in parallel or in a sequence.
  • Make the list configurable on the backend in order to release independently from app release cycles

We called the project “Widgetify”.

From idea to production:

After we had an initial feeling of “it works in theory,” we created a Feature flag and pushed it to our master branch (although we didn’t enable the feature in production, not even internally).

We then created the base classes with the first port of call being the list with the default content showing. We were able to Filter and Sort the list and everything (even analytics) was working as before. At this stage we enabled the feature internally by default, in order to catch errors a.s.a.p.

Next up, we created a few dummy widgets and established the project was future proof.

On occasions, we found a legacy widget which didn’t work properly on the new platform, so we had to switch off the feature and fix that particular widget provider (at Skyscanner we are now doing 2 week release cycles per platform).

Finally, when we felt we had something to show the others, we started promoting the platform internally and collecting feedback from other teams. We did this by sitting next to a team for three days, helping them create their own content. In return we got valuable feedback and based on the collective feedbacks we iterated to make building new content even easier.

Cards and providers

Each card type has its own provider. In addition to the standard search results returned by the ItineraryProvider, you can see some of the other special/custom cards we already have.

Also, providers are chainable and can depend on other providers. You can register a provider which will only start after another provider.

Image 9

1 A new search triggers the PlaceholderProvider first (its job is to create four placeholder cards). After the placeholder provider finishes, the ItineraryProvider will make the request to our servers. We also have the AdvertCardProvider set up, which is chained after the placeholder provider. This, too, starts parallel with the itineraries provider.

2 After the itinerary provider gets results back from the server, we create the viewmodels of the cards. We have a special provider called TimetableCardProvider to show the summary of direct flights. If there are many direct flights for a route, then it will create a viewmodel and return it. However at the moment, we do not have enough direct flights for the provider to create this viewmodel, hence it not showing at this point.

3 After a few seconds, we receive updated itineraries from the server and the TimetableCardProvider is triggered again. This time the calculation finishes and results in a new card viewmodel that ends up in front of the list.

5 Finally, the AdvertCardProvider finishes and returns an advert card. This will be placed in fourth position, so when users scroll down, they will see it there.

How easy it is to add new Content?

This HTML provider can create a widget with any dynamic content defined on the server side. A concrete example could be a widget highlighting the days of the week on which there is a direct flight available for the current search. Upon tapping on the day, the user would be able to set the search parameters.

On top of the results, people could see this html widget helping to choose better days

For this, you would need to create three files:

HTMLCardProvider

#import “HTMLCardProvider.h”#import “HTMLCardViewModel.h”
@implementation HTMLCardProvider{ SKYHtmlWidgetClient *_htmlClient;}- (instancetype)init{ if (self = [super initWithType:SKYFlightsDayviewPipelineItemTypeCustomWebView parent:SKYFlightsDayviewPipelineItemTypePlaceholder]) { _htmlClient = [SKYHtmlWidgetClient new]; } return self;}-(void)start{ [[self getServiceResponse] continueWithBlock:^id _Nullable(BFTask<SKYHTMLWidgetServiceResponse *> * _Nonnull task) { // If we have a successful response, we get to this stage. In case of errors this block will not run. SKYHTMLWidgetServiceResponse *serviceResponse = task.result; HTMLCardViewModel *htmlViewModel = [[CustomWebViewCardViewModel alloc] initWithData:serviceResponse.data]; self.finished = YES; [self.delegate provider:self refreshCompletedWithModels:@[htmlViewModel]]; }}- (void) registerCellForCollectionView:(UICollectionView*)collectionView{ [collectionView registerClass:[HTMLCardViewModel cellClass] forCellWithReuseIdentifier:[HTMLCardViewModel cellName]];}@end

HTMLCardViewModel:

The ViewModel holds the backend rendered html, and the height of the cell.

HTMLCardCell:

#import “HTMLCardCell.h”@implementation HTMLCardCell{   UIWebView *_webview;}- (instancetype)initWithFrame:(CGRect)frame{   self = [super initWithFrame:frame];   if(self)   {      _webview = [[UIWebView alloc] initWithFrame:CGRectMake(0, 0, frame.size.width, frame.size.height)];      _webview.scrollView.scrollEnabled = NO;      _webview.scrollView.bounces = NO;      _webview.delegate = self;      [self.contentView addSubview:_webview];   }   return self;}- (BOOL)webView:(UIWebView*)webView shouldStartLoadWithRequest:(NSURLRequest*)request navigationType:(UIWebViewNavigationType)navigationType {   NSURL *URL = [request URL];   if ([[URL scheme] isEqualToString:@”skyscanner://”]) {   // deeplink to the appropriate page   [[UIApplication sharedApplication] openURL:[NSURL URLWithString:[URL absoluteString]]];      return NO;   }   return YES;}#pragma mark — Layout-(void)setViewModel:(HTMLCardViewModel *)viewModel{   _viewModel = viewModel;   [_webview loadHTMLString:_viewModel.htmlString baseURL:nil];}
- (void)viewDidLayoutSubviews{ [super viewDidLayoutSubviews]; [_webview setFrame:CGRectMake(0, 0, self.frame.size.width, self.frame.size.height)];}@end

Conclusion

We are also using the platform to create an example app with prototypes so we can try new ideas really quickly with users around the globe to validate the UX of our future features.

In addition, it is now a lot harder to break the basic functionality of the app, and dynamic configuration (e.g. feature flagging) is also a lot easier both on client and server side.

if ([FeatureFlag isHtmlWidgetEnabled]){   // create Provider   // register Provider}

The CardProvider will try fetching the data, but it will not create the ViewModel if the server is not returning the data. In future, we will only put kill switch feature flags into the app, so the backend can tell if the app should show something or not.

Combining this with a Backend-for-Frontend service, it is a lot easier to experiment and try out new things on the results page without changing the app’s code. We therefore do not need to go through the long App Store review process and the backend can serve a new widget anytime, which will be Live in minutes instead of weeks.

Like what you hear? Work with us

About the author

Outside of my daily work at Skyscanner, I am a huge fan of travel and photography. I love to explore the hidden gems of a country instead of the usual touristic places.

Zsombor Fuszenecker, Skyscanner

Skyscanner Engineering

Written by

We are the engineers at Skyscanner, and we are transforming the travel industry. Visit Skyscanner to see how we walk the talk https://www.skyscanner.net/