Skip to content

Instantly share code, notes, and snippets.

@JasonCanCode
Last active April 13, 2023 16:31
Show Gist options
  • Save JasonCanCode/72fd4aed8089ccc439a075c085742bf3 to your computer and use it in GitHub Desktop.
Save JasonCanCode/72fd4aed8089ccc439a075c085742bf3 to your computer and use it in GitHub Desktop.
Streamlined structure for inspecting a link and informing the right coordinator to take action.
import Foundation
/// Manages all the link handlers and routes a url to the appropriate one.
public class LinkRouter: NSObject {
private(set) static var shared: LinkRouter = LinkRouter()
public var appScheme: String?
private var handlers: [LinkHandlerType] = []
public init(appScheme: String? = nil) {
self.appScheme = appScheme
}
/// Convert a web link url to an app link url, if possible
public func convertUrlToAppLink(url: URL?) -> URL? {
guard let appScheme = appScheme,
let validURL = url,
let scheme = validURL.scheme,
scheme.starts(with: "http"),
let applinkURL = URL(string: "\(appScheme):/\(validURL.path)") else {
return url
}
return applinkURL
}
/// Checks if it has a router that can handle the url
/// - Parameter url: The url from the original link request
public func canHandle(url: URL) -> Bool {
for handler in handlers {
let link = Link(url: url, appScheme: appScheme)
if handler.canHandle(link: link) {
return true
}
}
return false
}
/**
Route an open url link request to a matched LinkHandler
- Parameter url: The url from the original link request
- Returns: true if the link was handled; false otherwise
*/
@discardableResult
public func route(url: URL) -> Bool {
let link = Link(url: url, appScheme: appScheme)
guard let handler = handlers.first(where: { $0.canHandle(link: link) }) else {
return false
}
return handler.handle(link: link)
}
public func addHandler(_ handler: LinkHandlerType) {
self.handlers.append(handler)
}
}
import UIKit
// MARK: - Deep Linking
extension AppDelegate {
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
OperationQueue.main.addOperation {
LinkRouter.shared.route(url: url)
}
return LinkRouter.shared.canHandle(url: url) ?? false
}
}
// MARK: - Example LinkHandlerType
public protocol GamesLinkDelegate: AnyObject {
func showGameHomecreen()
func showGameCollection(of username: String)
func showGameReviews(by username: String)
func showGameReview(of game: String)
}
public struct GamesLinkHandler: LinkHandlerType {
public let coordinator: GamesLinkDelegate
public let pathSchemes: [String] = [
"games",
"games/{username}",
"games/{username}/reviews",
"games/reviews/{game}"
]
public init(coordinator: GamesLinkDelegate) {
self.coordinator = coordinator
}
public func handle(link: Link) -> Bool {
guard let result = parameterExtraction(from: link.pathComponents, existingParams: link.params) else {
coordinator.showGameHomecreen()
return true
}
if let username = result.params["username"] {
handle(username: username, result: result)
} else if let game = result.params["game"], link.pathComponents.contains("reviews") {
coordinator.showGameReview(of: game)
}
return true
}
private func handle(username: String, result: PathResult) {
if result.trailingComponents.contains("reviews") {
coordinator.showGameReviews(by: username)
} else {
coordinator.showGameCollection(of: username)
}
}
}
// MARK: - Example Coordinator Adoption
class ParentTabViewController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
LinkRouter.shared.addHandler(GamesLinkHandler(coordinator: self))
}
}
extension ParentTabViewController: GamesLinkDelegate {
func showGameHomecreen() {
// ...
}
func showGameCollection(of username: String) {
// ...
}
func showGameReviews(by username: String) {
// ...
}
func showGameReview(of game: String) {
// ...
}
}
import Foundation
public protocol LinkHandlerType {
/// Provides all possible paths a handler should respond to, with dynamic
/// elements indicated with moustache placeholders.
var pathSchemes: [String] { get }
/// The first (unique) component of each pathScheme is used to determine
/// whehter a LinkHandler shoudl claim responsibility.
func canHandle(link: Link) -> Bool
/// Inspect the link and perform the appropraite action.
/// - Returns: Whether or not an action was performed using the link passed.
func handle(link: Link) -> Bool
}
public extension LinkHandlerType {
// MARK: - Computed Properties
var roots: [String] {
let rootComponents = pathSchemeCollection.compactMap { $0.first }
let uniqueRoots = Array(Set(rootComponents))
return uniqueRoots
}
private var parameterKeys: [String] {
let allComponents = pathSchemeCollection.reduce([], +)
let foundKeys = allComponents.filter(moustacheCheck)
let uniqueKeys = Array(Set(foundKeys))
return uniqueKeys
}
private var pathSchemeCollection: [[String]] {
return pathSchemes.map { $0.components(separatedBy: "/") }
}
// MARK: - Handling Methods
func canHandle(link: Link) -> Bool {
for root in roots where link.pathComponents.contains(root) {
return true
}
return false
}
/// Compared the path components with path schemes to find parameters to add.
/// - Parameter pathComponents: Elements of a URL
/// - Parameter existingParams: Parameters already extracted from a query. Existing parameters supercede found values with the same key.
/// - Returns: A `PathResult` containing all parameters and any path components found after the last extracted parameter.
func parameterExtraction(from pathComponents: [String], existingParams: [String: String] = [:]) -> PathResult? {
let longestSchemesFirst = pathSchemeCollection.sorted(by: { $0.count > $1.count })
for schemeComponents in longestSchemesFirst {
if let result = parameterExtraction(from: pathComponents, schemeComponents: schemeComponents) {
var uniqueParams: [String: String] = existingParams
for (key, value) in result.params where uniqueParams[key] == nil {
uniqueParams[key] = value
}
return PathResult(params: uniqueParams, trailingComponents: result.trailingComponents)
}
}
return nil
}
// swiftlint:enable line_length
private func parameterExtraction(from pathComponents: [String], schemeComponents: [String]) -> PathResult? {
guard let indexKeyDic = keyIndicesDic(from: pathComponents, schemeComponents: schemeComponents),
let lastIndex = indexKeyDic.keys.max(),
lastIndex < pathComponents.count else {
return nil
}
var params: [String: String] = [:]
for (pathIndex, comp) in pathComponents.enumerated() {
if let key = indexKeyDic[pathIndex] {
params[key] = comp
} else if comp != schemeComponents[pathIndex] {
return nil
}
}
if params.isEmpty {
return nil
}
let trailingComponents: [String] = pathComponents.count > lastIndex + 1
? Array(pathComponents[lastIndex + 1..<pathComponents.count])
: []
return PathResult(params: params, trailingComponents: trailingComponents)
}
private func keyIndicesDic(from pathComponents: [String], schemeComponents: [String]) -> [Int: String]? {
guard pathComponents.count == schemeComponents.count else {
return nil
}
var keyIndices: [Int] = []
var foundKeys: [String] = []
for key in parameterKeys {
if let index = schemeComponents.firstIndex(of: key) {
foundKeys.append(removeMoustache(from: key))
keyIndices.append(Int(index))
}
}
if keyIndices.isEmpty {
return nil
}
return Dictionary(uniqueKeysWithValues: zip(keyIndices, foundKeys))
}
private func moustacheCheck(_ text: String) -> Bool {
return text.hasPrefix("{") && text.hasSuffix("}")
}
private func removeMoustache(from text: String) -> String {
guard moustacheCheck(text) else {
return text
}
var shaved = text
shaved.removeFirst()
shaved.removeLast()
return shaved
}
}
import Foundation
/// Contains all parameters found in a URL, both through dynamic elements within the path and those found in a query.
/// Also provides any path components found after the last extracted parameter.
public struct PathResult {
/// Properties found either in a query or extracted from a path scheme
public let params: [String: String]
/// Any path components found after the last extracted parameter
public let trailingComponents: [String]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment