Use deep links to navigate to the right screen in complex apps
When we browse the web, sometimes we click on a product, and we see the Amazon app opening exactly on the page of that product. Other times, after answering a friend on Messenger, we take a look at their profile, and the Facebook app opens directly on it.
These are examples of navigation from one app to another using deep links. This technology lets us connect our users with some internal screens of our apps. These screens are deep into the navigation tree, but deep links let us reach them without asking the users to navigate to that screen manually.
Today, I’d like to show you how to set up deep links in your app and how to handle them in a scalable way.
Let’s start by creating a new Xcode project. We can select Swift as the programming language and SwiftUI as the user interface framework. Then, we can follow the prompt until Xcode opens for us with a standard SwiftUI project.
To configure the deep links, we need to:
- Select our project in the Project Navigator (⌘+1)
- Select our app under in the Target panel
- Select the Info tab
- Expand the last option, URL Types
- Click on the small
+button at the bottom of the panel
- Fill out the form that has appeared.
In the form, there are four fields we can fill plus another section for type properties. The only important parameter to set is the URL Scheme in the top-right. This parameter controls how the URL must be shaped for the app to recognize it.
So, for example, if we set it to be
deeplinkapp every time the user clicks on a link on Safari that has the shape
deeplinkapp://, iOS will ask the user whether they want to open the URL in our app or not. Whenever another app tries to
UIApplication.shared.openURL("deeplinkapp://"), iOS will ask the user whether they want to open the URL in our or not.
An app can respond to multiple URL Schemes. This can be useful to direct users to different tabs or different sub-sections of the app. We can also support different schemes if our app is known by different names. To add more URL Schemes, click on the small
+ button on the bottom and configure them.
SwiftUI makes many things very easy. Responding to a deep link is one of those things. To respond to a deep link, we need to add the
onOpenURL modifier to any
View that is loaded when the app launches. The code can look like this:
Notice that we can also add multiple
onOpenURL modifiers in complex view. When this happens, it is interesting to understand how they are handled. Consider a situation like this:
When a deep link is passed to the app, we can observe this output:
There is no evident logic in how the link is propagated: the system asks first to
ContentView1 then to
ContentView2 (that is contained by a different container). After that, instead of asking the sibling of
ContentView2, it queries the external container, then the internal container and, finally, the last view.
We can’t make any assumption on how the deep links will be passed to the rendered views.
When an application grows, it is not feasible to try and handle all the possible navigation paths in a single place. If we try to do that, we are going to violate many software engineering principles, and our code could soon become spaghetti code: our deep link manager has to know all the possible screens we can reach with deep links, access different data sources, and create the data for all the screens.
That’s not good architecture, and it can become complex very quickly. There is a better approach to it: we can use the Chain of Responsibility pattern and split the task among different objects.
Chain of responsibility pattern
The pattern allows us to structure hierarchically multiple objects involved in solving a specific problem. All these objects share the same interface, and they have to carry out a similar task: for example, they have to decide whether they can handle a deep link and how to handle it.
The hierarchy can be queried in two directions: bottoms-up, from the children to the root, or top-down, from the root to the children.
In a bottoms-up approach, the leaf is the first element queried. It checks first whether it can carry out the task. If yes, the task is done. If not, it asks its parent to do the required task. The parents follow the same process: check whether it can do the job and, if not, it asks its parent, and so on until they find something able to execute the task or the system reports the impossibility of executing it.
We can find this pattern in many areas of software engineering. Touches in our views are managed in this way. Exceptions are thrown using this pattern. When we build complex SwiftUI views, we delegate the rendering of subviews to their
How to implement it at scale
We are considering the case of a complex application, split into modules, which has a single
CompositionRoot that is the only element that knows the whole app. It’s the
CompositionRoot that creates the hierarchy of
As the first step, let’s define a protocol for the
The protocol defines that a
DeepLinkParser should have two methods: one to check whether it can handle a deep link and another one to actually handle it.
Then, we can implement how to specifically handle part of the deep link in each module without leaking any information from other modules.
For example, the
TabDeepLinkParser could be an object that parses the initial part of the deep link and switches between different tabs.
Its implementation can look like this:
TabDeepLinkParser is initialized with an array of tabs, an array of other
DeepLinkPasers and an action to be performed if the deep link can be handled.
Then, we implement the first method of the protocol:
canHandleDeepLink. This method returns
true if it can handle the link. The
TabBar can handle the link when the
host of the URL is one of the tabs and if there is at least a child of this parser that can handle the link properly.
Given that we are querying all the children, the check for the possibility to handle the deep link mustn’t require a lot of work or intensive operations. We want to know if we can handle the link as fast as we can.
Finally, we implement the
handleDeepLink by executing the action and then asking the children that can handle the deep link to actually handle it.
Note: This is one of the possible implementations. Depending on your app logic you may not want to loop through all the possible handlers. You could look for the first or for the last, for example. You may also implement some sort of priority queue, depending on the structure of the deep link.
Then, we can just repeat this pattern in different modules, handling different parts of the deep link. A
ProductList view, for example, could implement a deep link parser that checks for the presence of an
id parameter and do something if that parameter is there:
This parser has no children, meaning that it is a leaf parser in our chain. It knows how to handle a deep link with the
id paramenter and it knows that it has to perform an action with the
id it extracts.
CompositionRoot can put the parser together. All the parsers are pieces of pure logic and don’t have any reference to the UI. At the end of the day, we want to use them to navigate to the proper screen. The
CompositionRoot bridges the parsers’ logic with the UI by creating the hierarchy of deep link parsers and by feeding them the proper actions.
An example implementation is the following:
DeepLinkApp works as the
CompositionRoot. It declares two
@State variables to keep track of the current selection. Then it defines the tabs we want to use in the app.
body property contains the
MainTabView with our tabs, and it uses the
onOpenURL modifier to handle the deep link.
When a deep link is received, it creates the proper hierarchy of parsers: the
TabDeepLinkParser with a
ProductDeepLinkParser child. The action used in the first parser changes the
selectedTab property. The action used in the second parser selects the proper product.
In today’s article, we learned what deep links are and how to structure an app to handle them. We also learned how to keep the parsers’ logic separated from each other. This improves how composable the solution is, and it simplifies the logic we have to write in every single parser.
We learned about the Chain of Responsibility pattern and how it can be used to compose the parsers together.
Everything discussed today can be found in this GitHub repository. It doesn’t contain only the code we saw in this article. It also contains other interesting things, like how to implement this same system without using
AnyView, if we accept to know in advance which tabs we will need to create, for example.
Deep links are very powerful, and this article just scratched their surface. They can be used to implement many other sophisticated functionalities — from testing to tracking and much more.