Skip to content

Commit 192bcff

Browse files
authored
[LOOP-3840] alert presenter can dismiss an alert not at the top of the alert stack (#450)
* presenter can dismiss an alert not at the top of the alert stack * more consistent naming * response to PR comments
1 parent 443f4fd commit 192bcff

File tree

5 files changed

+121
-35
lines changed

5 files changed

+121
-35
lines changed

Loop/Managers/Alerts/InAppModalAlertIssuer.swift

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public class InAppModalAlertIssuer: AlertIssuer {
1414
private weak var alertPresenter: AlertPresenter?
1515
private weak var alertManagerResponder: AlertManagerResponder?
1616

17-
private var alertsShowing: [Alert.Identifier: (UIAlertController, Alert)] = [:]
17+
private var alertsPresented: [Alert.Identifier: (UIAlertController, Alert)] = [:]
1818
private var alertsPending: [Alert.Identifier: (Timer, Alert)] = [:]
1919

2020
typealias ActionFactoryFunction = (String?, UIAlertAction.Style, ((UIAlertAction) -> Void)?) -> UIAlertAction
@@ -29,7 +29,8 @@ public class InAppModalAlertIssuer: AlertIssuer {
2929
alertManagerResponder: AlertManagerResponder,
3030
soundPlayer: AlertSoundPlayer = DeviceAVSoundPlayer(),
3131
newActionFunc: @escaping ActionFactoryFunction = UIAlertAction.init,
32-
newTimerFunc: TimerFactoryFunction? = nil) {
32+
newTimerFunc: TimerFactoryFunction? = nil)
33+
{
3334
self.alertPresenter = alertPresenter
3435
self.alertManagerResponder = alertManagerResponder
3536
self.soundPlayer = soundPlayer
@@ -38,7 +39,7 @@ public class InAppModalAlertIssuer: AlertIssuer {
3839
return Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: repeats) { _ in block?() }
3940
}
4041
}
41-
42+
4243
public func issueAlert(_ alert: Alert) {
4344
switch alert.trigger {
4445
case .immediate:
@@ -52,21 +53,30 @@ public class InAppModalAlertIssuer: AlertIssuer {
5253

5354
public func retractAlert(identifier: Alert.Identifier) {
5455
DispatchQueue.main.async {
55-
self.alertsPending[identifier]?.0.invalidate()
56-
self.clearPendingAlert(identifier: identifier)
57-
self.removeDeliveredAlert(identifier: identifier, completion: nil)
56+
self.removePendingAlert(identifier: identifier)
57+
self.removePresentedAlert(identifier: identifier)
5858
}
5959
}
60-
61-
func removeDeliveredAlert(identifier: Alert.Identifier, completion: (() -> Void)?) {
62-
self.alertsShowing[identifier]?.0.dismiss(animated: true, completion: completion)
63-
self.clearDeliveredAlert(identifier: identifier)
60+
61+
func removePresentedAlert(identifier: Alert.Identifier, completion: (() -> Void)? = nil) {
62+
guard let alertPresented = alertsPresented[identifier] else {
63+
completion?()
64+
return
65+
}
66+
alertPresenter?.dismissAlert(alertPresented.0, animated: true, completion: completion)
67+
clearPresentedAlert(identifier: identifier)
68+
}
69+
70+
func removePendingAlert(identifier: Alert.Identifier) {
71+
guard let alertPending = alertsPending[identifier] else { return }
72+
alertPending.0.invalidate()
73+
clearPendingAlert(identifier: identifier)
6474
}
6575
}
6676

6777
/// Private functions
6878
extension InAppModalAlertIssuer {
69-
79+
7080
private func schedule(alert: Alert, interval: TimeInterval, repeats: Bool) {
7181
guard alert.foregroundContent != nil else {
7282
return
@@ -90,59 +100,60 @@ extension InAppModalAlertIssuer {
90100
return
91101
}
92102
DispatchQueue.main.async {
93-
if self.isAlertShowing(identifier: alert.identifier) {
103+
if self.isAlertPresented(identifier: alert.identifier) {
94104
return
95105
}
96106
let alertController = self.constructAlert(title: content.title,
97107
message: content.body,
98108
action: content.acknowledgeActionButtonLabel,
99109
isCritical: content.isCritical) { [weak self] in
100-
self?.clearDeliveredAlert(identifier: alert.identifier)
101-
self?.alertManagerResponder?.acknowledgeAlert(identifier: alert.identifier)
110+
// the completion is called after the alert is acknowledged
111+
self?.clearPresentedAlert(identifier: alert.identifier)
112+
self?.alertManagerResponder?.acknowledgeAlert(identifier: alert.identifier)
102113
}
103114
self.alertPresenter?.present(alertController, animated: true) { [weak self] in
104-
// the completion is called after the alert is displayed
115+
// the completion is called after the alert is presented
105116
self?.playSound(for: alert)
106-
self?.addDeliveredAlert(alert: alert, controller: alertController)
117+
self?.addPresentedAlert(alert: alert, controller: alertController)
107118
}
108119
}
109120
}
110121

111122
private func addPendingAlert(alert: Alert, timer: Timer) {
112123
dispatchPrecondition(condition: .onQueue(.main))
113-
self.alertsPending[alert.identifier] = (timer, alert)
124+
alertsPending[alert.identifier] = (timer, alert)
114125
}
115126

116-
private func addDeliveredAlert(alert: Alert, controller: UIAlertController) {
127+
private func addPresentedAlert(alert: Alert, controller: UIAlertController) {
117128
dispatchPrecondition(condition: .onQueue(.main))
118-
self.alertsShowing[alert.identifier] = (controller, alert)
129+
alertsPresented[alert.identifier] = (controller, alert)
119130
}
120131

121132
private func clearPendingAlert(identifier: Alert.Identifier) {
122133
dispatchPrecondition(condition: .onQueue(.main))
123134
alertsPending[identifier] = nil
124135
}
125136

126-
private func clearDeliveredAlert(identifier: Alert.Identifier) {
137+
private func clearPresentedAlert(identifier: Alert.Identifier) {
127138
dispatchPrecondition(condition: .onQueue(.main))
128-
alertsShowing[identifier] = nil
139+
alertsPresented[identifier] = nil
129140
}
130-
141+
131142
private func isAlertPending(identifier: Alert.Identifier) -> Bool {
132143
dispatchPrecondition(condition: .onQueue(.main))
133144
return alertsPending.index(forKey: identifier) != nil
134145
}
135146

136-
private func isAlertShowing(identifier: Alert.Identifier) -> Bool {
147+
private func isAlertPresented(identifier: Alert.Identifier) -> Bool {
137148
dispatchPrecondition(condition: .onQueue(.main))
138-
return alertsShowing.index(forKey: identifier) != nil
149+
return alertsPresented.index(forKey: identifier) != nil
139150
}
140151

141-
private func constructAlert(title: String, message: String, action: String, isCritical: Bool, completion: @escaping () -> Void) -> UIAlertController {
152+
private func constructAlert(title: String, message: String, action: String, isCritical: Bool, acknowledgeCompletion: @escaping () -> Void) -> UIAlertController {
142153
dispatchPrecondition(condition: .onQueue(.main))
143154
// For now, this is a simple alert with an "OK" button
144155
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
145-
alertController.addAction(newActionFunc(action, .default, { _ in completion() }))
156+
alertController.addAction(newActionFunc(action, .default, { _ in acknowledgeCompletion() }))
146157
return alertController
147158
}
148159

Loop/Managers/DeliveryUncertaintyAlertManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class DeliveryUncertaintyAlertManager {
3939
self.showUncertainDeliveryRecoveryView()
4040
}
4141
alert.addAction(action)
42-
self.alertPresenter.dismiss(animated: false) {
42+
self.alertPresenter.dismissTopMost(animated: false) {
4343
self.alertPresenter.present(alert, animated: animated)
4444
}
4545
self.uncertainDeliveryAlert = alert

Loop/Managers/LoopAppManager.swift

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,20 @@ public protocol AlertPresenter: AnyObject {
2525
/// - Parameters:
2626
/// - animated: Animate the alert view controller dismissal or not.
2727
/// - completion: Completion to call once view controller is dismissed.
28-
func dismiss(animated: Bool, completion: (() -> Void)?)
28+
func dismissTopMost(animated: Bool, completion: (() -> Void)?)
29+
30+
/// Dismiss an alert, even if it is not the top most alert.
31+
/// - Parameters:
32+
/// - alertToDismiss: The alert to dismiss
33+
/// - animated: Animate the alert view controller dismissal or not.
34+
/// - completion: Completion to call once view controller is dismissed.
35+
func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?)
2936
}
3037

3138
public extension AlertPresenter {
3239
func present(_ viewController: UIViewController, animated: Bool) { present(viewController, animated: animated, completion: nil) }
33-
func dismiss(animated: Bool) { dismiss(animated: animated, completion: nil) }
40+
func dismissTopMost(animated: Bool) { dismissTopMost(animated: animated, completion: nil) }
41+
func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool) { dismissAlert(alertToDismiss, animated: animated, completion: nil) }
3442
}
3543

3644
protocol WindowProvider: AnyObject {
@@ -297,9 +305,56 @@ extension LoopAppManager: AlertPresenter {
297305
rootViewController?.topmostViewController.present(viewControllerToPresent, animated: animated, completion: completion)
298306
}
299307

300-
func dismiss(animated: Bool, completion: (() -> Void)?) {
308+
func dismissTopMost(animated: Bool, completion: (() -> Void)?) {
301309
rootViewController?.topmostViewController.dismiss(animated: animated, completion: completion)
302310
}
311+
312+
func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) {
313+
if rootViewController?.topmostViewController == alertToDismiss {
314+
dismissTopMost(animated: animated, completion: completion)
315+
} else {
316+
// check if the alert to dismiss is presenting another alert (and so on)
317+
// calling dismiss() on an alert presenting another alert will only dismiss the presented alert
318+
// (and any other alerts presented by the presented alert)
319+
320+
// get the stack of presented alerts that would be undesirably dismissed
321+
var presentedAlerts: [UIAlertController] = []
322+
var currentAlert = alertToDismiss
323+
while let presentedAlert = currentAlert.presentedViewController as? UIAlertController {
324+
presentedAlerts.append(presentedAlert)
325+
currentAlert = presentedAlert
326+
}
327+
328+
if presentedAlerts.isEmpty {
329+
alertToDismiss.dismiss(animated: animated, completion: completion)
330+
} else {
331+
// Do not animate any of these view transitions, since the alert to dismiss is not at the top of the stack
332+
333+
// dismiss all the child presented alerts.
334+
// Calling dismiss() on a VC that is presenting an other VC will dismiss the presented VC and all of its child presented VCs
335+
alertToDismiss.dismiss(animated: false) {
336+
// dismiss the desired alert
337+
// Calling dismiss() on a VC that is NOT presenting any other VCs will dismiss said VC
338+
alertToDismiss.dismiss(animated: false) {
339+
// present the child alerts that were undesirably dismissed
340+
var orderedPresentationBlock: (() -> Void)? = nil
341+
for alert in presentedAlerts.reversed() {
342+
if alert == presentedAlerts.last {
343+
orderedPresentationBlock = {
344+
self.present(alert, animated: false, completion: completion)
345+
}
346+
} else {
347+
orderedPresentationBlock = {
348+
self.present(alert, animated: false, completion: orderedPresentationBlock)
349+
}
350+
}
351+
}
352+
orderedPresentationBlock?()
353+
}
354+
}
355+
}
356+
}
357+
}
303358
}
304359

305360
// MARK: - DeviceOrientationController

LoopTests/Managers/Alerts/AlertManagerTests.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ class AlertManagerTests: XCTestCase {
6666

6767
class MockPresenter: AlertPresenter {
6868
func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { completion?() }
69-
func dismiss(animated: Bool, completion: (() -> Void)?) { completion?() }
69+
func dismissTopMost(animated: Bool, completion: (() -> Void)?) { completion?() }
70+
func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { completion?() }
7071
}
7172

7273
class MockSoundVendor: AlertSoundVendor {

LoopTests/Managers/Alerts/InAppModalAlertIssuerTests.swift

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class InAppModalAlertIssuerTests: XCTestCase {
4242

4343
class MockViewController: UIViewController, AlertPresenter {
4444
var viewControllerPresented: UIViewController?
45+
var alertDismissed: UIAlertController?
4546
var autoComplete = true
4647
var completion: (() -> Void)?
4748
override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
@@ -52,6 +53,21 @@ class InAppModalAlertIssuerTests: XCTestCase {
5253
self.completion = completion
5354
}
5455
}
56+
func dismissTopMost(animated: Bool, completion: (() -> Void)?) {
57+
if autoComplete {
58+
completion?()
59+
} else {
60+
self.completion = completion
61+
}
62+
}
63+
func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) {
64+
alertDismissed = alertToDismiss
65+
if autoComplete {
66+
completion?()
67+
} else {
68+
self.completion = completion
69+
}
70+
}
5571
func callCompletion() {
5672
completion?()
5773
}
@@ -170,14 +186,17 @@ class InAppModalAlertIssuerTests: XCTestCase {
170186
inAppModalAlertIssuer.issueAlert(alert)
171187

172188
waitOnMain()
189+
let alertControllerPresented = mockViewController.viewControllerPresented as? UIAlertController
190+
XCTAssertNotNil(alertControllerPresented)
191+
173192
var dismissed = false
174-
inAppModalAlertIssuer.removeDeliveredAlert(identifier: alert.identifier) {
193+
inAppModalAlertIssuer.removePresentedAlert(identifier: alert.identifier) {
175194
dismissed = true
176195
}
177-
196+
178197
waitOnMain()
179-
let alertController = mockViewController.viewControllerPresented as? UIAlertController
180-
XCTAssertNotNil(alertController)
198+
let alertDimissed = mockViewController.alertDismissed
199+
XCTAssertNotNil(alertDimissed)
181200
XCTAssertTrue(dismissed)
182201
}
183202

0 commit comments

Comments
 (0)