From e9ad1e82c0ec70f6c0e1543c7da97834310d2b16 Mon Sep 17 00:00:00 2001 From: Nataliya Patsovska Date: Thu, 25 Jul 2019 23:18:21 +0200 Subject: [PATCH 1/3] iOS 13 modal sheets with custom presentation delegate --- .circleci/config.yml | 10 +- .../StylesAndOptions/Example/AppFlow.swift | 14 +- .../Example/ChooseOptions.swift | 8 +- .../StylesAndOptions/Example/Utilities.swift | 27 +++- .../ExampleUITests/ExampleUITests.swift | 127 ++++++++++++------ Presentation.xcodeproj/project.pbxproj | 4 + .../CustomAdaptivePresentationDelegate.swift | 94 +++++++++++++ Presentation/PresentationOptions.swift | 3 + Presentation/PresentationStyle.swift | 32 ++++- .../UIViewController+Presentation.swift | 11 +- build.sh | 4 +- 11 files changed, 282 insertions(+), 52 deletions(-) create mode 100644 Presentation/CustomAdaptivePresentationDelegate.swift diff --git a/.circleci/config.yml b/.circleci/config.yml index ea3c8c7..30816fa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,5 +1,9 @@ version: 2 +common: &common + macos: + xcode: "11.0.0" + env: global: - LC_CTYPE=en_US.UTF-8 @@ -14,8 +18,7 @@ jobs: - run: swiftlint --strict test-iOS: - macos: - xcode: "10.2.0" + <<: *common steps: - checkout - run: @@ -28,8 +31,7 @@ jobs: sh build.sh test-iOS examples: - macos: - xcode: "10.2.0" + <<: *common steps: - checkout - run: diff --git a/Examples/StylesAndOptions/Example/AppFlow.swift b/Examples/StylesAndOptions/Example/AppFlow.swift index 1ed2d71..7a1ed06 100644 --- a/Examples/StylesAndOptions/Example/AppFlow.swift +++ b/Examples/StylesAndOptions/Example/AppFlow.swift @@ -63,13 +63,25 @@ extension AppFlow: Presentable { return containerController.present(Presentation(TestNavigationBarHiding(), style: .modal)).toVoid() } + if options.contains(.allowSwipeDismissAlways) { + struct NavigationStack: Presentable { + func materialize() -> (UIViewController, Disposable) { + let (vc, _) = TapToDismiss().materialize() + vc.present(TapToDismiss()) + + return (vc, NilDisposer()) + } + } + return containerController.present(NavigationStack(), style: style, options: options) + } + if let alertToPresent = alertToPresent { presentation = .right(Presentation(alertToPresent, style: style, options: options, configure: withDismiss)) } else { - presentation = .left(Presentation(TapToDismiss(), + presentation = .left(Presentation(TapToDismiss(showAlertOnDidAttemptToDismiss: options.contains(.showAlertOnDidAttemptToDismiss)), style: style, options: options, configure: style.name == "default" ? { _, _ in } : withDismiss)) diff --git a/Examples/StylesAndOptions/Example/ChooseOptions.swift b/Examples/StylesAndOptions/Example/ChooseOptions.swift index 31d629a..72fc2eb 100644 --- a/Examples/StylesAndOptions/Example/ChooseOptions.swift +++ b/Examples/StylesAndOptions/Example/ChooseOptions.swift @@ -18,6 +18,7 @@ struct ChoosePresentationOptions { } extension PresentationOptions { static let navigationBarPreference = PresentationOptions() + static let showAlertOnDidAttemptToDismiss = PresentationOptions() } extension PresentationOptions { @@ -38,8 +39,11 @@ extension PresentationOptions { ("Auto Pop (for navigation vc)", .autoPop), ("Auto Pop Successors (for navigation vc)", .autoPopSuccessors), ("Auto Pop Self And Successors (for navigation vc)", .autoPopSelfAndSuccessors), - ("NavigationBar visibility preference", .navigationBarPreference) - ] + ("NavigationBar visibility preference", .navigationBarPreference), + ("Show alert on swipe down to dismiss", .showAlertOnDidAttemptToDismiss), + ("Embed in navigation and swipe down to dismiss", [.showAlertOnDidAttemptToDismiss, embedInNavigationController]), + ("Allow swipe to dismiss always", [.allowSwipeDismissAlways, .defaults]) + ] return DataSource(options: presentationOptions.map { NamedPresentationOptions(name: $0.0, value: $0.1) }) diff --git a/Examples/StylesAndOptions/Example/Utilities.swift b/Examples/StylesAndOptions/Example/Utilities.swift index 0f4c54d..e30edd5 100644 --- a/Examples/StylesAndOptions/Example/Utilities.swift +++ b/Examples/StylesAndOptions/Example/Utilities.swift @@ -17,20 +17,43 @@ extension UIBarButtonItem { } } -struct TapToDismiss { } +struct TapToDismiss { + let showAlertOnDidAttemptToDismiss: Bool + + init(showAlertOnDidAttemptToDismiss: Bool = false) { + self.showAlertOnDidAttemptToDismiss = showAlertOnDidAttemptToDismiss + } +} extension TapToDismiss: Presentable { public func materialize() -> (UIViewController, Future<()>) { let vc = UIViewController() + if #available(iOS 13.0, *) { + #if compiler(>=5.1) + vc.isModalInPresentation = showAlertOnDidAttemptToDismiss + #endif + } let button = UIButton() button.setTitle("Tap To Dismiss", for: .normal) button.backgroundColor = .blue vc.view = button return (vc, Future<()> { completion in - return button.onValue { + let bag = DisposeBag() + if #available(iOS 13.0, *) { + let delegate = CustomAdaptivePresentationDelegate() + bag.hold(delegate) + + vc.customAdaptivePresentationDelegate = delegate + bag += delegate.didAttemptToDismissSignal.onValue { _ in + let alertAction = Alert<()>.Action(title: "OK", action: { }) + vc.present(Alert(message: "Test alert", actions: [alertAction])) + } + } + bag += button.onValue { completion(.success) } + return bag }) } } diff --git a/Examples/StylesAndOptions/ExampleUITests/ExampleUITests.swift b/Examples/StylesAndOptions/ExampleUITests/ExampleUITests.swift index 970c811..b9915a8 100644 --- a/Examples/StylesAndOptions/ExampleUITests/ExampleUITests.swift +++ b/Examples/StylesAndOptions/ExampleUITests/ExampleUITests.swift @@ -21,7 +21,7 @@ class ExampleUITests: XCTestCase { let style = "default" verifyForAllContainerConfigurations { - showDismissablePresentation(style: style, option: "Default") + chooseStyleAndOption(style: style, option: "Default") let isSideBySideSplitView = app.launchArguments.contains("UseSplitViewContainer") && UIDevice.current.userInterfaceIdiom == .pad @@ -30,23 +30,23 @@ class ExampleUITests: XCTestCase { XCTAssertTrue(initialScreenVisible) } - showDismissablePresentation(style: style, option: "Default") + chooseStyleAndOption(style: style, option: "Default") // completing the presentation doesn't dismiss the pushed vc automatically, we need to pass .autoPop for that pressDismiss() pressDismiss() pressBack() XCTAssertTrue(initialScreenVisible) - showDismissablePresentation(style: style, option: "Auto Pop Self And Successors (for navigation vc)") + chooseStyleAndOption(style: style, option: "Auto Pop Self And Successors (for navigation vc)") pressDismiss() XCTAssertTrue(initialScreenVisible) // no special behaviour for navigation presentation - showDismissablePresentation(style: style, option: "Fail On Block (for modal/popover vc)") + chooseStyleAndOption(style: style, option: "Fail On Block (for modal/popover vc)") pressBack() XCTAssertTrue(initialScreenVisible) - showDismissablePresentation(style: style, option: "Unanimated") + chooseStyleAndOption(style: style, option: "Unanimated") pressBack() XCTAssertTrue(initialScreenVisible) } @@ -57,22 +57,22 @@ class ExampleUITests: XCTestCase { let cancel = app.navigationBars["UIView"].buttons["Cancel"] verifyForAllContainerConfigurations { - showDismissablePresentation(style: style, option: "Default") + chooseStyleAndOption(style: style, option: "Default") XCTAssertFalse(cancel.exists) pressDismiss() XCTAssertTrue(initialScreenVisible) - showDismissablePresentation(style: style, option: "Embed In Navigation Controller") + chooseStyleAndOption(style: style, option: "Embed In Navigation Controller") XCTAssertFalse(cancel.exists) pressDismiss() XCTAssertTrue(initialScreenVisible) - showDismissablePresentation(style: style, option: "Unanimated") + chooseStyleAndOption(style: style, option: "Unanimated") XCTAssertFalse(cancel.exists) pressDismiss() XCTAssertTrue(initialScreenVisible) - showDismissablePresentation(style: style, option: "Fail On Block (for modal/popover vc)") + chooseStyleAndOption(style: style, option: "Fail On Block (for modal/popover vc)") XCTAssertTrue(initialScreenVisible) } } @@ -82,22 +82,22 @@ class ExampleUITests: XCTestCase { let cancel = app.navigationBars["UIView"].buttons["Cancel"] verifyForAllContainerConfigurations { - showDismissablePresentation(style: style, option: "Embed In Navigation Controller") - cancel.tap() + chooseStyleAndOption(style: style, option: "Embed In Navigation Controller") + cancel.waitForExistenceAndTap() XCTAssertTrue(initialScreenVisible) - showDismissablePresentation(style: style, option: "Embed In Navigation Controller") + chooseStyleAndOption(style: style, option: "Embed In Navigation Controller") pressDismiss() XCTAssertTrue(initialScreenVisible) - showDismissablePresentation(style: style, option: "Default") - cancel.tap() + chooseStyleAndOption(style: style, option: "Default") + cancel.waitForExistenceAndTap() XCTAssertTrue(initialScreenVisible) - showDismissablePresentation(style: style, option: "Fail On Block (for modal/popover vc)") + chooseStyleAndOption(style: style, option: "Fail On Block (for modal/popover vc)") XCTAssertTrue(initialScreenVisible) - showDismissablePresentation(style: style, option: "Unanimated") + chooseStyleAndOption(style: style, option: "Unanimated") XCTAssertEqual(cancel.exists, false) pressDismiss() XCTAssertTrue(initialScreenVisible) @@ -111,11 +111,11 @@ class ExampleUITests: XCTestCase { let okButton = app.sheets.buttons["OK"] verifyForAllContainerConfigurations { - showDismissablePresentation(style: style, option: "Fail On Block (for modal/popover vc)") + chooseStyleAndOption(style: style, option: "Fail On Block (for modal/popover vc)") XCTAssertEqual(okButton.exists, false) - showDismissablePresentation(style: style, option: "Default") - okButton.tap() + chooseStyleAndOption(style: style, option: "Default") + okButton.waitForExistenceAndTap() XCTAssertTrue(initialScreenVisible) } } @@ -124,7 +124,7 @@ class ExampleUITests: XCTestCase { let style = "embed" verifyForAllContainerConfigurations { - showDismissablePresentation(style: style, option: "Default") + chooseStyleAndOption(style: style, option: "Default") XCTAssertTrue(initialScreenVisible) pressDismiss() @@ -136,37 +136,76 @@ class ExampleUITests: XCTestCase { let style = "invisible" verifyForAllContainerConfigurations { - showDismissablePresentation(style: style, option: "Default") + chooseStyleAndOption(style: style, option: "Default") XCTAssertTrue(initialScreenVisible) } } - // Issue: https://github.com/iZettle/Presentation/issues/36 - func disabled_testNavigationBarVisibilityPreference() { + func testSwipeDownToDismissModal() { + if #available(iOS 13.0, *) { + let style = "modal" + let dismissButton = app.buttons["Tap To Dismiss"] + let navBar = app.navigationBars["UIView"] + + func swipeDown(afterExistenseOf requiredElement: XCUIElement) { + XCTAssertTrue(requiredElement.waitForExistence(timeout: 1.0)) + app.swipeDown() + } + + func dragDownFromNavigationBar(to toElement: XCUIElement, afterExistenseOf requiredElement: XCUIElement) { + XCTAssertTrue(requiredElement.waitForExistence(timeout: 1.0)) + navBar.press(forDuration: 0.5, thenDragTo: toElement) + } + + verifyForAllContainerConfigurations { + chooseStyleAndOption(style: style, option: "Show alert on swipe down to dismiss") + swipeDown(afterExistenseOf: dismissButton) + pressAlertOK() + dismissButton.tap() + + chooseStyleAndOption(style: style, option: "Embed in navigation and swipe down to dismiss") + swipeDown(afterExistenseOf: dismissButton) + pressAlertOK() + dismissButton.tap() + + // Drag modal down and dismiss it + chooseStyleAndOption(style: style, option: "Default") + dragDownFromNavigationBar(to: dismissButton, afterExistenseOf: dismissButton) + XCTAssertFalse(dismissButton.exists) + + // When in navigation stack with more than one view controller, dragging down dismisses a view only if that option has been passed + chooseStyleAndOption(style: style, option: "Allow swipe to dismiss always") + dragDownFromNavigationBar(to: dismissButton, afterExistenseOf: navBar.buttons["Back"]) + XCTAssertFalse(dismissButton.exists) + } + } + } + + func testNavigationBarVisibilityPreference() { app.launch() - showDismissablePresentation(style: "default", option: "NavigationBar visibility preference") + chooseStyleAndOption(style: "default", option: "NavigationBar visibility preference") let navBar = app.navigationBars["UIView"] let nextButton = app.buttons["Next"] let backButton = navBar.buttons["Back"] XCTAssertTrue(navBar.exists) - nextButton.tap() + nextButton.waitForExistenceAndTap() XCTAssertFalse(navBar.exists) - nextButton.tap() + nextButton.waitForExistenceAndTap() XCTAssertTrue(navBar.exists) - backButton.tap() + backButton.waitForExistenceAndTap() XCTAssertFalse(navBar.exists) - nextButton.tap() + nextButton.waitForExistenceAndTap() XCTAssertTrue(navBar.exists) - nextButton.tap() + nextButton.waitForExistenceAndTap() XCTAssertFalse(navBar.exists) - nextButton.tap() + nextButton.waitForExistenceAndTap() XCTAssertFalse(navBar.exists) app.terminate() @@ -185,26 +224,38 @@ class ExampleUITests: XCTestCase { } } - func showDismissablePresentation(style: String, option: String) { + func chooseStyleAndOption(style: String, option: String, file: StaticString = #file, line: UInt = #line) { let tablesQuery = app.tables - XCTAssertTrue(app.navigationBars["Presentation Styles"].exists) + XCTAssertTrue(app.navigationBars["Presentation Styles"].exists, file: file, line: line) tablesQuery.cells.staticTexts[style].tap() - XCTAssertTrue(app.navigationBars["Presentation Options"].exists) + XCTAssertTrue(app.navigationBars["Presentation Options"].exists, file: file, line: line) tablesQuery.cells.staticTexts[option].tap() } - func pressBack() { - let back = app.navigationBars["UIView"].buttons.firstMatch - back.tap() + func pressAlertOK(file: StaticString = #file, line: UInt = #line) { + let okButton = app.alerts.buttons["OK"] + okButton.waitForExistenceAndTap(file: file, line: line) } - func pressDismiss() { + func pressBack(file: StaticString = #file, line: UInt = #line) { + let back = app.navigationBars["UIView"].buttons.firstMatch + back.waitForExistenceAndTap(file: file, line: line) + } + + func pressDismiss(file: StaticString = #file, line: UInt = #line) { let dismiss = app.buttons["Tap To Dismiss"] - dismiss.tap() + dismiss.waitForExistenceAndTap(file: file, line: line) } var initialScreenVisible: Bool { return app.navigationBars["Presentation Styles"].exists } } + +extension XCUIElement { + func waitForExistenceAndTap(file: StaticString = #file, line: UInt = #line) { + XCTAssert(self.waitForExistence(timeout: 1.0), file: file, line: line) + self.tap() + } +} diff --git a/Presentation.xcodeproj/project.pbxproj b/Presentation.xcodeproj/project.pbxproj index 0bd9dd8..f3d180f 100644 --- a/Presentation.xcodeproj/project.pbxproj +++ b/Presentation.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1C0656EB1F8290CB00E60465 /* MemoryUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0656EA1F8290CB00E60465 /* MemoryUtils.swift */; }; 1C0656ED1F8393C100E60465 /* MemoryUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0656EC1F8393C100E60465 /* MemoryUtilsTests.swift */; }; 2177C8F31D897360000DECA4 /* Presentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2177C8F21D897360000DECA4 /* Presentable.swift */; }; + 722FE3B622EA357A00EB04A7 /* CustomAdaptivePresentationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722FE3B522EA357A00EB04A7 /* CustomAdaptivePresentationDelegate.swift */; }; 728711A2229818B700A086DF /* PresentationEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 728711A1229818B700A086DF /* PresentationEvent.swift */; }; F617E3991C197D7600B567FB /* UIViewController+Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F617E3981C197D7600B567FB /* UIViewController+Presentation.swift */; }; F646BEDB1C85CF5400AA7526 /* Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = F646BEDA1C85CF5400AA7526 /* Alert.swift */; }; @@ -45,6 +46,7 @@ 1C0656EA1F8290CB00E60465 /* MemoryUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryUtils.swift; sourceTree = ""; }; 1C0656EC1F8393C100E60465 /* MemoryUtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryUtilsTests.swift; sourceTree = ""; }; 2177C8F21D897360000DECA4 /* Presentable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Presentable.swift; sourceTree = ""; }; + 722FE3B522EA357A00EB04A7 /* CustomAdaptivePresentationDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomAdaptivePresentationDelegate.swift; sourceTree = ""; }; 728711A1229818B700A086DF /* PresentationEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PresentationEvent.swift; sourceTree = ""; }; B38092B720A9B718009D8302 /* Flow.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flow.framework; path = Carthage/Build/iOS/Flow.framework; sourceTree = ""; }; F617E3751C197D5E00B567FB /* Presentation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Presentation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -126,6 +128,7 @@ F66934091E79384400F83529 /* PresentationStyle.swift */, F669340D1E7939E000F83529 /* PresentationOptions.swift */, F617E3981C197D7600B567FB /* UIViewController+Presentation.swift */, + 722FE3B522EA357A00EB04A7 /* CustomAdaptivePresentationDelegate.swift */, F6A2D4971EA9DF3B00C93D9A /* Presentation.swift */, 728711A1229818B700A086DF /* PresentationEvent.swift */, F65D5DC61C58C546002B5D95 /* PresentingViewController.swift */, @@ -311,6 +314,7 @@ F617E3991C197D7600B567FB /* UIViewController+Presentation.swift in Sources */, F66292A1206911BA00DDE0DF /* UINavigationController+Presenting.swift in Sources */, F669340E1E7939E000F83529 /* PresentationOptions.swift in Sources */, + 722FE3B622EA357A00EB04A7 /* CustomAdaptivePresentationDelegate.swift in Sources */, F68AF6061F30AB6F009C28A9 /* MasterDetailSelection.swift in Sources */, 1C0656EB1F8290CB00E60465 /* MemoryUtils.swift in Sources */, F6B3B6FC1F398E1B00188409 /* KeepSelection.swift in Sources */, diff --git a/Presentation/CustomAdaptivePresentationDelegate.swift b/Presentation/CustomAdaptivePresentationDelegate.swift new file mode 100644 index 0000000..4f08483 --- /dev/null +++ b/Presentation/CustomAdaptivePresentationDelegate.swift @@ -0,0 +1,94 @@ +// +// CustomAdaptivePresentationDelegate.swift +// Presentation +// +// Created by Vasil Blanco-Nunev on 2019-07-19. +// Copyright © 2019 iZettle. All rights reserved. +// + +import UIKit +import Flow + +/// Exposes a reactive interface for a delegate conforming to `UIAdaptivePresentationControllerDelegate` +public final class CustomAdaptivePresentationDelegate: NSObject, UIAdaptivePresentationControllerDelegate { + public var adaptivePresentationStyle = Delegate<(UIPresentationController, UITraitCollection?), UIModalPresentationStyle>() + public var viewControllerForAdaptivePresentationStyle = Delegate<(UIPresentationController, UIModalPresentationStyle), UIViewController?>() + public var shouldDismiss = Delegate() + + private let willPresentCallbacker = Callbacker() + private let willDismissCallbacker = Callbacker() + private let didAttemptToDismissCallbacker = Callbacker() + private let didDismissCallbacker = Callbacker() + + public typealias WillPresentAdaptivelyInput = (UIPresentationController, UIModalPresentationStyle, UIViewControllerTransitionCoordinator?) + + public var willPresentSignal: Signal { + return Signal(callbacker: willPresentCallbacker) + } + + /// A signal that fires on iOS 13+ when a modal presentation will get dismissed by swiping down. + /// + /// - Note: It fires when the dismissed view controller has `isModalInPresentation` set to `false` + public var willDismissSignal: Signal { + return Signal(callbacker: willDismissCallbacker) + } + + /// A signal that fires on iOS 13+ when a modal presentation attemts to get dismissed by swiping down + /// + /// - Note: For this to get called, the dismissed view controller needs to have `isModalInPresentation` set to `true` + public var didAttemptToDismissSignal: Signal { + return Signal(callbacker: didAttemptToDismissCallbacker) + } + + /// A signal that fires on iOS 13+ when a modal presentation gets dismissed by swiping down + public var didDismissSignal: Signal { + return Signal(callbacker: didDismissCallbacker) + } + + // MARK: - UIAdaptivePresentationControllerDelegate + public func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { + return adaptivePresentationStyle.call((controller, nil)) ?? controller.adaptivePresentationStyle + } + + public func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return adaptivePresentationStyle.call((controller, traitCollection)) ?? controller.adaptivePresentationStyle + } + + public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) { + willPresentCallbacker.callAll(with: (presentationController, style, transitionCoordinator)) + } +} + +#if compiler(>=5.1) +@available(iOS 13.0, *) +public extension CustomAdaptivePresentationDelegate { + func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { + willDismissCallbacker.callAll(with: presentationController) + } + + func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + didAttemptToDismissCallbacker.callAll(with: presentationController) + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + didDismissCallbacker.callAll(with: presentationController) + } + + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return shouldDismiss.call(presentationController) ?? true + } +} + +public extension UIViewController { + /// A custom delegate that allows you to listen to a Signals for adaptive presentations. + /// + /// **Warning:** When presenting using Presentation, do not use your own presentationController delegate. + /// Presentation is performing extra work on this delegate to make sure things are disposed properly. + var customAdaptivePresentationDelegate: CustomAdaptivePresentationDelegate? { + get { return associatedValue(forKey: &customAdaptivePresentationDelegateKey) } + set { objc_setAssociatedObject(self, &customAdaptivePresentationDelegateKey, newValue, .OBJC_ASSOCIATION_ASSIGN) } + } +} + +private var customAdaptivePresentationDelegateKey = false +#endif diff --git a/Presentation/PresentationOptions.swift b/Presentation/PresentationOptions.swift index e7b4271..c4e7c4c 100644 --- a/Presentation/PresentationOptions.swift +++ b/Presentation/PresentationOptions.swift @@ -44,6 +44,9 @@ public extension PresentationOptions { /// Disable any presentation animations. static let unanimated = PresentationOptions() + /// Allow modal dismissal with swipe down gesture when the presented view controller is inside a navigation stack + static let allowSwipeDismissAlways = PresentationOptions() + /// Default options used unless any options are explicity passed when presented: `[embedInNavigationController]` static let defaults: PresentationOptions = [embedInNavigationController] diff --git a/Presentation/PresentationStyle.swift b/Presentation/PresentationStyle.swift index e6495b5..d204b9e 100644 --- a/Presentation/PresentationStyle.swift +++ b/Presentation/PresentationStyle.swift @@ -31,7 +31,7 @@ public struct PresentationStyle { /// Presents `viewController` from `fromViewController` using `options`. public func present(_ viewController: UIViewController, from fromViewController: UIViewController, options: PresentationOptions) -> Result { do { - return try _present(viewController, fromViewController, options) + return try _present(viewController, fromViewController, options) } catch { return (dismisser: { Future() }, result: Future(error: error)) } @@ -99,7 +99,35 @@ public extension PresentationStyle { return from.modallyPresentQueued(vc, options: options) { Future { completion in let bag = DisposeBag() - bag += viewController.installDismissButton().onValue { completion(.failure(PresentError.dismissed)) } + + // The presentationController of an alert controller should not have its delegate modified + if !(vc is UIAlertController) { + let customPresentationDelegate: CustomAdaptivePresentationDelegate + if let givenDelegate = viewController.customAdaptivePresentationDelegate { + customPresentationDelegate = givenDelegate + } else { + customPresentationDelegate = CustomAdaptivePresentationDelegate() + bag.hold(customPresentationDelegate) + } + + bag += customPresentationDelegate.shouldDismiss.set { presentationController -> Bool in + guard !options.contains(.allowSwipeDismissAlways), + let nc = (presentationController.presentedViewController as? UINavigationController) else { + return true + } + return nc.viewControllers.count <= 1 + } + + vc.presentationController?.delegate = customPresentationDelegate + + bag += customPresentationDelegate.didDismissSignal.onValue { _ in + completion(.failure(PresentError.dismissed)) + } + } + + bag += viewController.installDismissButton().onValue { + completion(.failure(PresentError.dismissed)) + } if vc.modalPresentationStyle == .popover, let popover = vc.popoverPresentationController { let delegate = PopoverPresentationControllerDelegate { diff --git a/Presentation/UIViewController+Presentation.swift b/Presentation/UIViewController+Presentation.swift index d1e1a74..1b7d282 100644 --- a/Presentation/UIViewController+Presentation.swift +++ b/Presentation/UIViewController+Presentation.swift @@ -19,7 +19,16 @@ public extension UIViewController { func present(_ viewController: VC, style: PresentationStyle = .default, options: PresentationOptions = .defaults, function: @escaping (VC, DisposeBag) -> Future) -> Future { let vc = viewController let root = rootViewController - guard (root.isViewLoaded && root.view.window != nil) || unitTestDisablePresentWaitForWindow else { + + // iOS 13 temporary fix for issue #40: https://github.com/iZettle/Presentation/issues/40 + let shouldPresentImmediately: Bool + if #available(iOS 13.0, *) { + shouldPresentImmediately = root is UISplitViewController || vc is UISplitViewController + } else { + shouldPresentImmediately = false + } + + guard shouldPresentImmediately || (root.isViewLoaded && root.view.window != nil) || unitTestDisablePresentWaitForWindow else { // Wait for root to be presented before presenting vc return root.signal(for: \.view).flatMapLatest { $0.hasWindowSignal.atOnce() }.filter { $0 }.future.flatMap { _ in self.present(vc, style: style, options: options, function: function) diff --git a/build.sh b/build.sh index cd09e2e..991d1fa 100644 --- a/build.sh +++ b/build.sh @@ -7,8 +7,8 @@ set -o pipefail PROJECT="Presentation.xcodeproj" SCHEME="Presentation" -IOS_SDK="iphonesimulator12.2" -IOS_DESTINATION_PHONE="OS=12.2,name=iPhone X" +IOS_SDK="iphonesimulator13.0" +IOS_DESTINATION_PHONE="OS=13.0,name=iPhone Xs" usage() { cat << EOF From 3f37b282584fa0860c05f1c0afcd7cd1e411cf56 Mon Sep 17 00:00:00 2001 From: Nataliya Patsovska Date: Fri, 26 Jul 2019 11:02:19 +0200 Subject: [PATCH 2/3] Fix documentation and compiling on Xcode 10 --- .../CustomAdaptivePresentationDelegate.swift | 8 +++---- Presentation/PresentationStyle.swift | 21 +++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/Presentation/CustomAdaptivePresentationDelegate.swift b/Presentation/CustomAdaptivePresentationDelegate.swift index 4f08483..bb9e26c 100644 --- a/Presentation/CustomAdaptivePresentationDelegate.swift +++ b/Presentation/CustomAdaptivePresentationDelegate.swift @@ -33,14 +33,14 @@ public final class CustomAdaptivePresentationDelegate: NSObject, UIAdaptivePrese return Signal(callbacker: willDismissCallbacker) } - /// A signal that fires on iOS 13+ when a modal presentation attemts to get dismissed by swiping down + /// A signal that fires on iOS 13+ when a modal presentation attemts to get dismissed by swiping down /// /// - Note: For this to get called, the dismissed view controller needs to have `isModalInPresentation` set to `true` public var didAttemptToDismissSignal: Signal { return Signal(callbacker: didAttemptToDismissCallbacker) } - /// A signal that fires on iOS 13+ when a modal presentation gets dismissed by swiping down + /// A signal that fires on iOS 13+ when a modal presentation gets dismissed by swiping down public var didDismissSignal: Signal { return Signal(callbacker: didDismissCallbacker) } @@ -78,9 +78,10 @@ public extension CustomAdaptivePresentationDelegate { return shouldDismiss.call(presentationController) ?? true } } +#endif public extension UIViewController { - /// A custom delegate that allows you to listen to a Signals for adaptive presentations. + /// A custom delegate that allows you to listen to signals for adaptive presentations. /// /// **Warning:** When presenting using Presentation, do not use your own presentationController delegate. /// Presentation is performing extra work on this delegate to make sure things are disposed properly. @@ -91,4 +92,3 @@ public extension UIViewController { } private var customAdaptivePresentationDelegateKey = false -#endif diff --git a/Presentation/PresentationStyle.swift b/Presentation/PresentationStyle.swift index d204b9e..2ec75f7 100644 --- a/Presentation/PresentationStyle.swift +++ b/Presentation/PresentationStyle.swift @@ -102,15 +102,16 @@ public extension PresentationStyle { // The presentationController of an alert controller should not have its delegate modified if !(vc is UIAlertController) { - let customPresentationDelegate: CustomAdaptivePresentationDelegate - if let givenDelegate = viewController.customAdaptivePresentationDelegate { - customPresentationDelegate = givenDelegate - } else { - customPresentationDelegate = CustomAdaptivePresentationDelegate() - bag.hold(customPresentationDelegate) - } + /** + Using a custom property instead of `viewController.presentationController?.delegate` because + of a memory leak in UIKit when accessing the presentation controller of a view controller + that's not going to be presented: https://github.com/iZettle/Presentation/pull/43#discussion_r307223478 + */ + let delegate = viewController.customAdaptivePresentationDelegate ?? CustomAdaptivePresentationDelegate() + bag.hold(delegate) + vc.presentationController?.delegate = delegate - bag += customPresentationDelegate.shouldDismiss.set { presentationController -> Bool in + bag += delegate.shouldDismiss.set { presentationController -> Bool in guard !options.contains(.allowSwipeDismissAlways), let nc = (presentationController.presentedViewController as? UINavigationController) else { return true @@ -118,9 +119,7 @@ public extension PresentationStyle { return nc.viewControllers.count <= 1 } - vc.presentationController?.delegate = customPresentationDelegate - - bag += customPresentationDelegate.didDismissSignal.onValue { _ in + bag += delegate.didDismissSignal.onValue { _ in completion(.failure(PresentError.dismissed)) } } From 94789c338cbad6888e1ac82f6e2e79d799cda234 Mon Sep 17 00:00:00 2001 From: Nataliya Patsovska Date: Fri, 26 Jul 2019 11:23:09 +0200 Subject: [PATCH 3/3] Bump version to 1.7.0 --- CHANGELOG.md | 4 ++++ Presentation/Info.plist | 2 +- PresentationFramework.podspec | 2 +- PresentationTests/Info.plist | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49cdbac..5fa3419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 1.7.0 +- [Big fix] Fix presentation lifecycle management on iOS 13 when swiping down a modal sheet +- [Addition] Expose a custom adaptive presentation delegate with reactive interface + # 1.6.1 - Update Flow dependency and pin it to a compatible version diff --git a/Presentation/Info.plist b/Presentation/Info.plist index 148a1d1..01a700f 100644 --- a/Presentation/Info.plist +++ b/Presentation/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 1.6.1 + 1.7.0 CFBundleSignature ???? CFBundleVersion diff --git a/PresentationFramework.podspec b/PresentationFramework.podspec index f952e92..779cf36 100644 --- a/PresentationFramework.podspec +++ b/PresentationFramework.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "PresentationFramework" - s.version = "1.6.1" + s.version = "1.7.0" s.module_name = "Presentation" s.summary = "Driving presentations from model to result" s.description = <<-DESC diff --git a/PresentationTests/Info.plist b/PresentationTests/Info.plist index f95e64f..43b4ca8 100644 --- a/PresentationTests/Info.plist +++ b/PresentationTests/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.6.1 + 1.7.0 CFBundleVersion 1