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.xcworkspacewith 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)
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)
}
}
...
class AppCurrentError extends AppPaper {
AppCurrentError(this.text);
final String text;
}
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);
}
}
...
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 checkdidLaunchedWithUrl's value. If it'strue, it triggers MethodChannel with the name 'onNewUrl', which will send the URL to the Flutter platform app. If it'sfalse, we setdidLaunchedWithUrltotrueand setinitialUrlto any URL if the app was opened by deep linking.
App
-
At the
AppInitializehandler, now we update it a little bit. IfAppis opened by an external URL (deep linking),Appwill show loading and commandNetworkDaoto fetch the coin details with the ID extracted from the URL. If the URL is empty or has an invalid format,Appwill proceed as usual before. -
During the lifecycle of being active, if
Appreceives a URL from an external source, just like it handles other papers, we proceed to check if the URL is valid, thenAppwill react accordingly. -
In this case, if the incoming URL is for
current-coin,Appwill processAppCoinDetailsSelection. Otherwise, it processesAppCurrentErrorto show an error popup.