Last active
April 13, 2023 16:31
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) { | |
// ... | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
/// Converts a `URL` into a form ideal for use with a `LinkHandlerType` | |
public struct Link { | |
/// The url from the original link request | |
public let url: URL | |
/** | |
The relative path components being considered for resolving a handler. | |
These are initially extracted from the original url. Typically the first element is matched against | |
the available handlers. If matched, the request is passed to the handler. | |
*/ | |
public let pathComponents: [String] | |
/** | |
The query params. These are initially extracted from the original url. | |
They may be processed or altered as needed during the routing process. | |
*/ | |
public let params: [String: String] | |
private let appScheme: String? | |
/// Separates and preserves the path components and parameters found in a URL | |
/// - Parameter url: The url from the original link request | |
/// - Parameter appScheme: Can be used to check against the url scheme and preserve the host | |
public init(url: URL, appScheme: String?) { | |
self.url = url | |
self.appScheme = appScheme | |
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { | |
self.pathComponents = [] | |
self.params = [:] | |
return | |
} | |
var path = components.path.components(separatedBy: "/").dropFirst() | |
if let host = components.host, | |
let scheme = components.scheme, | |
appScheme == scheme { | |
path = [host] + path | |
} | |
var params = [String: String]() | |
for param in components.queryItems ?? [] { | |
params[param.name] = param.value | |
} | |
self.pathComponents = Array(path) | |
self.params = params | |
} | |
/// Converts a deeplink formatted url into a standard one using the provided host | |
/// - Parameter host: The desired host of the standardized link | |
public func standardizedURL(forHost host: String) -> URL? { | |
guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true), | |
urlComponents.scheme == appScheme else { | |
return url | |
} | |
urlComponents.scheme = "https" | |
if let urlHost = urlComponents.host { | |
urlComponents.path = "/" + urlHost + urlComponents.path | |
} | |
urlComponents.host = host | |
urlComponents.query = "mobile=ios" | |
return urlComponents.url | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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