summaryrefslogtreecommitdiffhomepage
path: root/ios/Routing/Coordinator.swift
blob: bfe240b9c91414138daf92573a1ea66a77b91437 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
//
//  Coordinator.swift
//  MullvadVPN
//
//  Created by pronebird on 27/01/2023.
//  Copyright © 2025 Mullvad VPN AB. All rights reserved.
//

import MullvadLogging
import UIKit

/**
 Base coordinator class.

 Coordinators help to abstract the navigation and business logic from view controllers making them
 more manageable and reusable.
 */
@MainActor
open class Coordinator: NSObject {
    /// Private trace log.
    private lazy var logger = Logger(label: "\(Self.self)")

    /// Weak reference to parent coordinator.
    private weak var _parent: Coordinator?

    /// Mutable collection of child coordinators.
    private var _children: [Coordinator] = []

    /// Modal presentation configuration assigned on presented coordinator.
    fileprivate var modalConfiguration: ModalPresentationConfiguration?

    /// An array of blocks that are invoked upon interactive dismissal.
    fileprivate var interactiveDismissalObservers: [(Coordinator) -> Void] = []

    /// Child coordinators.
    public var childCoordinators: [Coordinator] {
        _children
    }

    /// Parent coordinator.
    public var parent: Coordinator? {
        _parent
    }

    // MARK: - Children

    /**
     Add child coordinator.
    
     Adding the same coordinator twice is a no-op.
     */
    public func addChild(_ child: Coordinator) {
        guard !_children.contains(child) else { return }

        _children.append(child)
        child._parent = self

        logger.trace("Add child \(child)")
    }

    /**
     Remove child coordinator.
    
     Removing coordinator that's no longer a child of this coordinator is a no-op.
     */
    public func removeChild(_ child: Coordinator) {
        guard let index = _children.firstIndex(where: { $0 == child }) else { return }

        _children.remove(at: index)
        child._parent = nil

        logger.trace("Remove child \(child)")
    }

    /**
     Remove coordinator from its parent.
     */
    public func removeFromParent() {
        _parent?.removeChild(self)
    }
}

/**
 Protocol describing coordinators that can be presented using modal presentation.
 */
public protocol Presentable: Coordinator, Sendable {
    /**
     View controller that is presented modally. It's expected it to be the topmost view controller
     managed by coordinator.
     */
    var presentedViewController: UIViewController { get }
}

/**
 Protocol describing `Presentable` coordinators that can be popped from a navigation stack.
 */
public protocol Poppable: Presentable {
    func popFromNavigationStack(
        animated: Bool,
        completion: (() -> Void)?
    )
}

/**
 Protocol describing coordinators that provide modal presentation context.
 */
public protocol Presenting: Coordinator {
    /**
     View controller providing modal presentation context.
     */
    var presentationContext: UIViewController { get }
}

extension Presenting where Self: Presentable {
    /**
     View controller providing modal presentation context.
     */
    public var presentationContext: UIViewController {
        return presentedViewController
    }
}

extension Presenting {
    /**
     Present child coordinator.
    
     Automatically adds child and removes it upon interactive dismissal.
     */
    public func presentChild(
        _ child: some Presentable,
        animated: Bool,
        configuration: ModalPresentationConfiguration = ModalPresentationConfiguration(),
        completion: (() -> Void)? = nil
    ) {
        var configuration = configuration

        configuration.notifyInteractiveDismissal { [weak child] in
            guard let child else { return }

            child.modalConfiguration = nil
            child.removeFromParent()

            let observers = child.interactiveDismissalObservers
            child.interactiveDismissalObservers = []

            for observer in observers {
                observer(child)
            }
        }

        configuration.apply(to: child.presentedViewController)

        child.modalConfiguration = configuration

        addChild(child)

        topmostPresentationContext(from: presentationContext).present(
            child.presentedViewController,
            animated: animated,
            completion: completion
        )
    }

    private func topmostPresentationContext(from: UIViewController) -> UIViewController {
        var context = presentationContext

        while let childContext = context.presentedViewController, context != childContext {
            context = childContext
        }

        return context
    }
}

extension Presentable {
    /**
     Dismiss this coordinator.
    
     Automatically removes itself from parent.
     */
    public func dismiss(animated: Bool, completion: (@MainActor () -> Void)? = nil) {
        removeFromParent()

        presentedViewController.dismiss(animated: animated, completion: completion)
    }

    /**
     Add block based observer triggered if coordinator is dismissed via user interaction.
     */
    public func onInteractiveDismissal(_ handler: @escaping @Sendable (Coordinator) -> Void) {
        interactiveDismissalObservers.append(handler)
    }
}