Deep Link

Deep linking in Flutter can be handled in many convenient approaches, developed by Flutter itself as well as third-party packages.

Despite the differences in each implementation, they all handle the deep link in one common way: receive a String, which is formatted as a URL, and build the navigator's stack based on the path processed from the URL. For example, if the URL is "https://app/home/detail?id=1", the app will update the navigation stack to have the Home screen at the bottom and the Details screen at the top with a parameter for the ID of 1. This is the most common way to handle deep links currently. We think it is NOT the best approach in terms of flexibility and declarative methodology.

Because of the idea behind approaches as shown above, it creates an assumption that when we think about deep linking, we immediately think about navigation, like navigation has to be the thing to handle deep linking. Actually, it should not have to be. We suggest that a deep link's URL should be treated as a normal event sent to the app to react to, like UI reacts to Bloc's State changes in the Bloc Pattern.

Recall the above example, let's say as required by a feature, the app has to show the Details screen when receiving the deep link's URL. With the more "traditional" way - "https://app/home/detail?id=1", the part "/home/detail" will be used by the app's navigation system to build a new stack accordingly: [Home, Details]. But why should the app care about the current stack? Does it make more sense when the app just needs to push the Detail Screen with the correct config? That means "/home/detail" is not quite needed. Instead, we should set the URL as "https://app/detail-selection?id=1". The "detail-selection?id=1" represents an event for the app to handle. Like we "train" the app that "Hey app, when you receive https://app/detail-selection?id=1, please push a Detail Screen, no matter the current stack". Similarly, if a feature requires the app to rebuild the whole navigation stack, we should set up the URL more declaratively: https://app/build-new-stack.

Bottom line, we advise you to consider a deep link's URL as an event rather than a navigation stack path.

Let's jump to the code:

In this example, we will implement the deep link handling by native code for iOS - Swift; and we use URL Schemes instead of Universal Links due to the lack of a web domain host. We recommend using Universal Links as there are more advantages to them. Here are the main references we use: IOS Deep linking, Deep Links and Flutter applications

  • Set up URL Schemes: Open Runner.xcworkspace with Xcode → Project Settings → Info → URL Types → Enter a value to URL Schemes: "demo" (You can set up your own value, just remember it when setting up the Deep link's URL)
ios/Runner/AppDelegate.swift
import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {

    private var methodChannel: FlutterMethodChannel?

    private var initialUrl: String?
    private var didLaunchedWithUrl: Bool = false

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
      let controller = window.rootViewController as! FlutterViewController

      methodChannel = FlutterMethodChannel(name: "poc.deeplink.flutter.dev/channel", binaryMessenger: controller  as! FlutterBinaryMessenger)

      methodChannel!.setMethodCallHandler({ (call: FlutterMethodCall, result: FlutterResult) -> Void in
          guard call.method == "initialLink" else {
              result(FlutterMethodNotImplemented)
              return
          }
          self.initialLink(result: result)
      })

      if(launchOptions?[.url] == nil){
          self.didLaunchedWithUrl = true
      }

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
        if(self.didLaunchedWithUrl){
            methodChannel?.invokeMethod("onNewUrl", arguments:  url.absoluteString)
        } else {
            self.didLaunchedWithUrl = true
            self.initialUrl = url.absoluteString
        }
        return true
  }

  private func initialLink(result: FlutterResult) {
        result(self.initialUrl)
  }
}
lib/src/unit_widget/app/app_paper.dart
...

class AppCurrentError extends AppPaper {
  AppCurrentError(this.text);

  final String text;
}
lib/src/unit_widget/app/app_script.dart
class AppScript extends Script<AppPaper, AppState> {
  
  void map() => on<AppCurrentTab>(onCurrentTab)
                ...
                ?.on<AppCurrentError>(onCurrentError);

  void onInitialize(
    AppInitialize p,
    AppState s,
    SourceVerifier ifFrom,
  ) async {
    try {
      final url = await s.startUrl();
      if (url == null) throw Exception();

      final uri = Uri.parse(url);
      final path = uri.pathSegments.last;

      switch (path) {
        case 'current-coin':
          final id = uri.queryParameters['id'];
          if (id == null || id.isEmpty) throw FormatException();

          s.showLoading();
          s.networkDao.process(
            NetworkDaoDataFetching(NetworkDaoCoinDetailsParam(coinId: id)),
          );
          break;
        default:
          throw FormatException();
      }
    } catch (e) {
      final page = 1;
      final category = s.criteria.selectedCategory;
      final currency = s.criteria.selectedCurrency;
      final timeFrame = s.criteria.selectedTimeFrame;
      s.networkDao.process(NetworkDaoDataFetching(
        NetworkDaoCoinsListParam(
          page: page,
          category: category,
          currency: currency,
          timeFrame: timeFrame,
        ),
      ));
    }
  }

  ...

  void onCurrentError(
    AppCurrentError p,
    AppState s,
    SourceVerifier ifFrom,
  ) {
    s.showErrorDialog(p.text);
  }
}
lib/src/unit_widget/app/app.dart
...

import 'package:flutter/services.dart';

...

class AppState extends UnitWidgetState<AppPaper, App> {
  ...

  final channel = MethodChannel('poc.deeplink.flutter.dev/channel');

  Future<String?> startUrl() async {
    try {
      final expectedUrl = await channel.invokeMethod('initialLink');
      if (expectedUrl is! String?) {
        throw FormatException();
      }
      return expectedUrl;
    } on PlatformException {
      rethrow;
    } on FormatException {
      rethrow;
    }
  }

  ...

  
  void initState() {
    super.initState();

    channel.setMethodCallHandler((call) async {
      if (call.method == 'onNewUrl') {
        onReceiveUrl(call.arguments);
      }
    });
  }

  ...

  void onReceiveUrl(dynamic expectedUrl) {
    try {
      final uri = Uri.parse(expectedUrl);
      final path = uri.pathSegments.last;

      switch (path) {
        case 'current-coin':
          final id = uri.queryParameters['id'];
          if (id == null || id.isEmpty) throw FormatException();
          report(AppCoinDetailsSelection(id));
          break;
        default:
          throw FormatException();
      }
    } catch (_) {
      report(AppCurrentError(l10n.s_DeepLinkNotFoundAlert));
    }
  }

  ...
}

Test the feature by CLI with an example of coin ID of cardano:

  • $ xcrun simctl openurl booted "demo://demo/current-coin?id=cardano"

When the app is in the foreground:

When the app is terminated:

Breakdown

AppDelegate

  • Regarding platform-specific code - MethodChannel, please refer to Writing platform-specific code.

  • In application(_:didFinishLaunchingWithOptions:), we establish the connection between platforms and check if the app has been opened by URLs.

  • In application(_:open:options:), we check didLaunchedWithUrl's value. If it's true, it triggers MethodChannel with the name 'onNewUrl', which will send the URL to the Flutter platform app. If it's false, we set didLaunchedWithUrl to true and set initialUrl to any URL if the app was opened by deep linking.

App

  • At the AppInitialize handler, now we update it a little bit. If App is opened by an external URL (deep linking), App will show loading and command NetworkDao to fetch the coin details with the ID extracted from the URL. If the URL is empty or has an invalid format, App will proceed as usual before.

  • During the lifecycle of being active, if App receives a URL from an external source, just like it handles other papers, we proceed to check if the URL is valid, then App will react accordingly.

  • In this case, if the incoming URL is for current-coin, App will process AppCoinDetailsSelection. Otherwise, it processes AppCurrentError to show an error popup.