diff --git a/ACKategories-iOS/UIView+Spacer.swift b/ACKategories-iOS/UIView+Spacer.swift new file mode 100644 index 00000000..7fad1855 --- /dev/null +++ b/ACKategories-iOS/UIView+Spacer.swift @@ -0,0 +1,61 @@ +import UIKit + +// not sure this doesn't crash on iOS 11, so make it unavailable as it cannot be really tested +@available(iOS 12, *) +public extension UIView { + private final class Spacer: UIView { + fileprivate var observation: NSKeyValueObservation? + + init( + size: CGFloat, + axis: NSLayoutConstraint.Axis, + priority: Float + ) { + super.init(frame: .init( + origin: .zero, + size: .init( + width: axis == .horizontal ? size : 0, + height: axis == .vertical ? size : 0 + ) + )) + + translatesAutoresizingMaskIntoConstraints = false + + switch axis { + case .horizontal: + let constraint = widthAnchor.constraint(equalToConstant: size) + constraint.priority = .init(priority) + constraint.isActive = true + case .vertical: + let constraint = heightAnchor.constraint(equalToConstant: size) + constraint.priority = .init(priority) + constraint.isActive = true + default: assertionFailure("Unknown axis \(axis)") + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + + private func createSpacer(_ size: CGFloat, axis: NSLayoutConstraint.Axis, priority: Float) -> UIView { + let spacer = Spacer(size: size, axis: axis, priority: priority) + spacer.isHidden = isHidden + spacer.observation = observe(\.isHidden) { [weak spacer] sender, _ in + spacer?.isHidden = sender.isHidden + } + return spacer + } + + /// Create vertical spacer whose `isHidden` is tied to `self.isHidden` + func createVSpacer(_ height: CGFloat, priority: Float = 999) -> UIView { + createSpacer(height, axis: .vertical, priority: priority) + } + + /// Create horizontal spacer whose `isHidden` is tied to `self.isHidden` + func createHSpacer(_ width: CGFloat, priority: Float = 999) -> UIView { + createSpacer(width, axis: .horizontal, priority: priority) + } +} + diff --git a/ACKategories-iOS/UIViewController+FrontMost.swift b/ACKategories-iOS/UIViewController+FrontMost.swift new file mode 100644 index 00000000..898506a1 --- /dev/null +++ b/ACKategories-iOS/UIViewController+FrontMost.swift @@ -0,0 +1,27 @@ +import UIKit + +/// Extend you container controllers with this protocol, to make sure `frontmostChild` and `frontmostController` properties +/// can work correctly +public protocol FrontmostContainerViewController { + /// Goes through view controller hierarchy and returns view controller on top + var frontmostChild: UIViewController? { get } +} + +extension UISplitViewController: FrontmostContainerViewController { + public var frontmostChild: UIViewController? { viewControllers.last } +} + +extension UINavigationController: FrontmostContainerViewController { + public var frontmostChild: UIViewController? { topViewController } +} + +extension UITabBarController: FrontmostContainerViewController { + public var frontmostChild: UIViewController? { selectedViewController } +} + +public extension UIViewController { + /// Returns frontmost controller that can be used e.g. for modal presentations + var frontmostController: UIViewController { + presentedViewController?.frontmostController ?? (self as? FrontmostContainerViewController)?.frontmostChild?.frontmostController ?? self + } +} diff --git a/ACKategories-iOSTests/UIViewTests.swift b/ACKategories-iOSTests/UIViewTests.swift index edf53cc3..65407934 100644 --- a/ACKategories-iOSTests/UIViewTests.swift +++ b/ACKategories-iOSTests/UIViewTests.swift @@ -26,4 +26,59 @@ final class UIViewTests: XCTestCase { XCTAssertEqual(view.contentCompressionResistancePriority(for: .horizontal), .required) XCTAssertEqual(view.contentCompressionResistancePriority(for: .vertical), .required) } + + func test_spacer_initialIsHidden() { + let view = UIView() + view.isHidden = false + let spacer = view.createVSpacer(10) + + XCTAssertEqual(view.isHidden, spacer.isHidden) + + let view2 = UIView() + view2.isHidden = true + let spacer2 = view2.createVSpacer(10) + + XCTAssertEqual(view2.isHidden, spacer2.isHidden) + } + + func test_spacer_isHiddenObservation() { + let view = UIView() + view.isHidden = false + let spacer = view.createVSpacer(10) + + XCTAssertEqual(view.isHidden, spacer.isHidden) + + view.isHidden = true + XCTAssertEqual(view.isHidden, spacer.isHidden) + } + + func test_spacer_isDeinited() throws { + let parent = UIView() + + weak var weakView: UIView? + weak var weakSpacer: UIView? + + try autoreleasepool { + var view: UIView? = UIView() + var spacer: UIView? = try XCTUnwrap(view).createVSpacer(10) + + try parent.addSubview(XCTUnwrap(view)) + try parent.addSubview(XCTUnwrap(spacer)) + + weakView = view + weakSpacer = spacer + + view = nil + spacer = nil + + XCTAssertNotNil(weakView) + XCTAssertNotNil(weakSpacer) + + weakView?.removeFromSuperview() + weakSpacer?.removeFromSuperview() + } + + XCTAssertNil(weakView) + XCTAssertNil(weakSpacer) + } } diff --git a/ACKategories.xcodeproj/project.pbxproj b/ACKategories.xcodeproj/project.pbxproj index d8a58e57..732ebe3a 100644 --- a/ACKategories.xcodeproj/project.pbxproj +++ b/ACKategories.xcodeproj/project.pbxproj @@ -80,12 +80,16 @@ 695096EF23C790A900E8F457 /* ACKategories.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 69E81A0723C773370054687B /* ACKategories.framework */; }; 695096F023C790A900E8F457 /* ACKategories.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 69E81A0723C773370054687B /* ACKategories.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 696DF854245304F400A6AC69 /* ReusableViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 696DF853245304F400A6AC69 /* ReusableViewTests.swift */; }; + 6984CE132A5C218A001EE958 /* UIViewController+FrontMost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6984CE122A5C218A001EE958 /* UIViewController+FrontMost.swift */; }; + 6984CE152A5C26AA001EE958 /* UIView+Spacer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6984CE142A5C26AA001EE958 /* UIView+Spacer.swift */; }; 69DB1A012831839F004B32D7 /* PublisherExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3478AA27E37DC1004548B3 /* PublisherExtensions.swift */; }; 69E819F423C773240054687B /* ACKategoriesCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 69E819EA23C773240054687B /* ACKategoriesCore.framework */; }; 69E819FB23C773240054687B /* ACKategoriesCore.h in Headers */ = {isa = PBXBuildFile; fileRef = 69E819ED23C773240054687B /* ACKategoriesCore.h */; settings = {ATTRIBUTES = (Public, ); }; }; 69E81A1023C773370054687B /* ACKategories.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 69E81A0723C773370054687B /* ACKategories.framework */; platformFilter = ios; }; 69E81A1723C773370054687B /* ACKategories_iOS.h in Headers */ = {isa = PBXBuildFile; fileRef = 69E81A0923C773370054687B /* ACKategories_iOS.h */; settings = {ATTRIBUTES = (Public, ); }; }; 69F7058E273ADBA3004DD190 /* IntExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F7058D273ADBA3004DD190 /* IntExtensions.swift */; }; + 69F83E962A6179B200E9C8EA /* Combine+Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F83E952A6179B200E9C8EA /* Combine+Concurrency.swift */; }; + 69F83E972A617A2500E9C8EA /* Combine+Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F83E952A6179B200E9C8EA /* Combine+Concurrency.swift */; }; 69FA5FAD23C868A900B44BCD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 69FA5F9023C868A900B44BCD /* Assets.xcassets */; }; 69FA5FAE23C868A900B44BCD /* UIControlBlocksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69FA5F9323C868A900B44BCD /* UIControlBlocksViewController.swift */; }; 69FA5FAF23C868A900B44BCD /* TitleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69FA5F9523C868A900B44BCD /* TitleViewController.swift */; }; @@ -227,6 +231,8 @@ 696DF853245304F400A6AC69 /* ReusableViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableViewTests.swift; sourceTree = ""; }; 697B023227DB65B50082F4AC /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; 697CECF023C877B20019FE61 /* Aliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aliases.swift; sourceTree = ""; }; + 6984CE122A5C218A001EE958 /* UIViewController+FrontMost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+FrontMost.swift"; sourceTree = ""; }; + 6984CE142A5C26AA001EE958 /* UIView+Spacer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Spacer.swift"; sourceTree = ""; }; 69E819EA23C773240054687B /* ACKategoriesCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ACKategoriesCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 69E819ED23C773240054687B /* ACKategoriesCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ACKategoriesCore.h; sourceTree = ""; }; 69E819EE23C773240054687B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -238,6 +244,7 @@ 69E81A0F23C773370054687B /* ACKategories-iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "ACKategories-iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 69E81A1623C773370054687B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 69F7058D273ADBA3004DD190 /* IntExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntExtensions.swift; sourceTree = ""; }; + 69F83E952A6179B200E9C8EA /* Combine+Concurrency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Combine+Concurrency.swift"; sourceTree = ""; }; 69FA5F9023C868A900B44BCD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 69FA5F9323C868A900B44BCD /* UIControlBlocksViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIControlBlocksViewController.swift; sourceTree = ""; }; 69FA5F9523C868A900B44BCD /* TitleViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TitleViewController.swift; sourceTree = ""; }; @@ -416,6 +423,7 @@ 6950965523C7754D00E8F457 /* Reusable.swift */, 6950965823C78AB900E8F457 /* StringExtensions.swift */, 6950967923C78AD000E8F457 /* UserDefaultsExtensions.swift */, + 69F83E952A6179B200E9C8EA /* Combine+Concurrency.swift */, 6950963423C7749500E8F457 /* Supporting files */, ); path = ACKategoriesCore; @@ -442,7 +450,6 @@ 69E81A0823C773370054687B /* ACKategories-iOS */ = { isa = PBXGroup; children = ( - 6950963023C7747C00E8F457 /* Base */, 697CECF023C877B20019FE61 /* Aliases.swift */, A38883E2257E2D2D00B958DD /* ErrorHandlers.swift */, 6950964823C7751600E8F457 /* GradientView.swift */, @@ -463,9 +470,12 @@ 6950965D23C78AC800E8F457 /* UISearchBarExtensions.swift */, 6950966823C78AC900E8F457 /* UIStackViewExtensions.swift */, 6950966523C78AC800E8F457 /* UIView+SafeAreaCompat.swift */, + 6984CE142A5C26AA001EE958 /* UIView+Spacer.swift */, 6950966923C78AC900E8F457 /* UIViewController+Children.swift */, + 6984CE122A5C218A001EE958 /* UIViewController+FrontMost.swift */, 6950966723C78AC800E8F457 /* UIViewController+SafeAreaCompat.swift */, 6950966323C78AC800E8F457 /* UIViewExtensions.swift */, + 6950963023C7747C00E8F457 /* Base */, 6950963523C7749F00E8F457 /* Supporting files */, ); path = "ACKategories-iOS"; @@ -474,16 +484,16 @@ 69E81A1323C773370054687B /* ACKategories-iOSTests */ = { isa = PBXGroup; children = ( - A3BA6859256BEC6A006DB42F /* Extensions */, - A3BA6858256BEC56006DB42F /* FlowCoordinator */, + 69E81A1623C773370054687B /* Info.plist */, 6950968D23C78CC200E8F457 /* ColorTests.swift */, 6950969423C78CC200E8F457 /* ControlBlocksTests.swift */, - 69E81A1623C773370054687B /* Info.plist */, 696DF853245304F400A6AC69 /* ReusableViewTests.swift */, + 6937271F257FB0A3007CE25C /* UINavigationControllerTests.swift */, 6950969123C78CC200E8F457 /* UIStackViewTests.swift */, 6950969023C78CC200E8F457 /* UIViewControllerChildrenTests.swift */, 6950969323C78CC200E8F457 /* UIViewTests.swift */, - 6937271F257FB0A3007CE25C /* UINavigationControllerTests.swift */, + A3BA6859256BEC6A006DB42F /* Extensions */, + A3BA6858256BEC56006DB42F /* FlowCoordinator */, ); path = "ACKategories-iOSTests"; sourceTree = ""; @@ -904,6 +914,7 @@ F885BD9A245AC4240071073D /* Int+Random.swift in Sources */, 693D773229A4BD1200B1AF0C /* BetterURL.swift in Sources */, 6950965023C7753500E8F457 /* NumberFormatterExtensions.swift in Sources */, + 69F83E962A6179B200E9C8EA /* Combine+Concurrency.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -947,6 +958,7 @@ F8B81695246C59F7005D1D74 /* Randomizable.swift in Sources */, 6950963D23C774DB00E8F457 /* DictionaryExtensions.swift in Sources */, 693D773329A4BD1200B1AF0C /* BetterURL.swift in Sources */, + 6984CE152A5C26AA001EE958 /* UIView+Spacer.swift in Sources */, 6950967423C78AC900E8F457 /* UIView+SafeAreaCompat.swift in Sources */, 6950963323C7748D00E8F457 /* ArrayExtensions.swift in Sources */, 6950962E23C7747800E8F457 /* FlowCoordinator.swift in Sources */, @@ -960,11 +972,13 @@ F8B81694246C59F7005D1D74 /* Date+Random.swift in Sources */, 6950966C23C78AC900E8F457 /* UISearchBarExtensions.swift in Sources */, 6950966B23C78AC900E8F457 /* UIDeviceExtensions.swift in Sources */, + 6984CE132A5C218A001EE958 /* UIViewController+FrontMost.swift in Sources */, 6950966F23C78AC900E8F457 /* UIApplicationExtensions.swift in Sources */, F8B81696246C59F7005D1D74 /* String+Random.swift in Sources */, 6950966D23C78AC900E8F457 /* UIColorExtensions.swift in Sources */, 6950967023C78AC900E8F457 /* UIControlEvents.swift in Sources */, 6950965723C7754D00E8F457 /* Reusable.swift in Sources */, + 69F83E972A617A2500E9C8EA /* Combine+Concurrency.swift in Sources */, 6950962A23C7740B00E8F457 /* Logger.swift in Sources */, 6950967823C78AC900E8F457 /* UIViewController+Children.swift in Sources */, 6950967223C78AC900E8F457 /* UIViewExtensions.swift in Sources */, diff --git a/ACKategoriesCore/Combine+Concurrency.swift b/ACKategoriesCore/Combine+Concurrency.swift new file mode 100644 index 00000000..030e446c --- /dev/null +++ b/ACKategoriesCore/Combine+Concurrency.swift @@ -0,0 +1,24 @@ +import Combine + +@available(macOS 10.15, iOS 13.0, *) +public extension Future where Failure == Error { + convenience init(operation: @escaping () async throws -> Output) { + self.init { promise in + Task { + do { + try await promise(.success(operation())) + } catch { + promise(.failure(error)) + } + } + } + } +} + +@available(macOS 10.15, iOS 13.0, *) +public extension AnyPublisher where Failure == Error { + init(operation: @escaping () async throws -> Output) { + self = Future { try await operation() }.eraseToAnyPublisher() + } +} +