diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d326b26..36c7387 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,18 +5,16 @@ on: jobs: test: - runs-on: macos-latest + runs-on: macos-26 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 + - name: Install SwiftLint + run: | + brew install swiftlint - name: Lint run: | swiftlint --strict - - name: Pod lib lint - run: | - gem install bundler - bundle install --jobs 4 --retry 3 - bundle exec pod lib lint - name: Test run: | - xcodebuild -scheme FuntastyKit-Package -destination 'platform=iOS Simulator,name=iPhone 8' -sdk iphonesimulator test + xcodebuild -scheme FuntastyKit -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -sdk iphonesimulator test diff --git a/.gitignore b/.gitignore index 3bf50c9..95c4320 100644 --- a/.gitignore +++ b/.gitignore @@ -1,71 +1,5 @@ -# Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - -## Build generated -build/ -DerivedData/ - -## Various settings -*.pbxuser -!default.pbxuser -*.mode1v3 -!default.mode1v3 -*.mode2v3 -!default.mode2v3 -*.perspectivev3 -!default.perspectivev3 +.DS_Store +/.build +/Packages +/*.xcodeproj xcuserdata/ - -## Other -*.moved-aside -*.xcuserstate - -## Obj-C/Swift specific -*.hmap -*.ipa -*.dSYM.zip -*.dSYM - -## Playgrounds -timeline.xctimeline -playground.xcworkspace - -# Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -.build/ -.swiftpm -Package.resolved - -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the -# screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots -fastlane/test_output -fastlane/README.md - -# Project, because it can be generated using SPM -*.xcodeproj diff --git a/.swiftlint.yml b/.swiftlint.yml index 5703ab3..6e73a8d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,17 +1,18 @@ disabled_rules: - line_length excluded: - - Pods - - vendor + - .build + - .claude +analyzer_rules: + - unused_declaration + - unused_import opt_in_rules: - - anyobject_protocol - array_init - attributes - closure_body_length - closure_end_indentation - closure_spacing - collection_alignment - - conditional_returns_on_newline - contains_over_filter_count - contains_over_filter_is_empty - contains_over_first_not_nil @@ -22,7 +23,6 @@ opt_in_rules: - empty_collection_literal - empty_count - empty_string - - empty_xctest_method - enum_case_associated_values_count - explicit_init - fallthrough @@ -34,24 +34,29 @@ opt_in_rules: - function_default_parameter_at_end - identical_operands - implicit_return + - implicitly_unwrapped_optional - joined_default_parameter - last_where - legacy_multiple - legacy_random + - let_var_whitespace - literal_expression_end_indentation - lower_acl_than_parent - modifier_order + - multiline_arguments - multiline_function_chains - multiline_literal_brackets - multiline_parameters - multiline_parameters_brackets - nimble_operator + - no_extension_access_modifier - number_separator - object_literal - operator_usage_whitespace - optional_enum_case_matching - overridden_super_call - override_in_extension + - pattern_matching_keywords - prefer_self_type_over_type_of_self - private_action - private_outlet @@ -69,8 +74,6 @@ opt_in_rules: - trailing_closure - unneeded_parentheses_in_closure_argument - untyped_error_in_catch - - unused_declaration - - unused_import - vertical_parameter_alignment_on_call - vertical_whitespace_closing_braces - yoda_condition @@ -82,6 +85,9 @@ identifier_name: - x - y - z + - pr + +type_body_length: 400 # Disable errors, allow only warnings cyclomatic_complexity: diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..276c170 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# CLAUDE.md — FuntastyKit + +## Build & Test + +This is an iOS framework — use the Xcode toolchain (`xcrun`), not the local Swift toolchain: + +```bash +# Build (requires iOS SDK via Xcode) +xcodebuild build -scheme FuntastyKit -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -sdk iphonesimulator + +# Run tests (matches CI) +xcodebuild test -scheme FuntastyKit -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -sdk iphonesimulator + +# Lint +swiftlint --strict +``` + +Note: `swift build` / `swift test` do not work because UIKit is not available on macOS host. Always use `xcodebuild` with an iOS simulator destination. + +## Project Structure + +Single SPM target: + +- **FuntastyKit** (`Sources/FuntastyKit/`) — Core: Coordinator pattern, error handling, UIKit extensions + +Tests are in `FuntastyKitTests`. + +## Architecture + +Swift 6.2 with explicit `@MainActor` annotations on all public protocols and UIKit-facing types. No `defaultIsolation` — all isolation is spelled out in source. Extensions on `@MainActor` protocols and UIKit classes inherit isolation automatically; only standalone types/extensions that create UIKit objects need their own `@MainActor`. + +Tests use Swift Testing (`import Testing`, `@Suite`, `@Test`). Types in the test target that conform to library protocols need explicit `@MainActor` annotations. + +Coordinator protocol hierarchy for UIKit navigation: +`Coordinator` → `DefaultCoordinator` → `ConfiguringCoordinator` → `PushCoordinator` / `ModalCoordinator` / `ShowCoordinator` / `TabBarItemCoordinator` + +Each coordinator type provides a default `start()` implementation for its navigation style. `CoordinatorDelegate` notifies on `willStop`/`didStop`. + +## SwiftLint + +Strict mode enabled. Key style rules to follow: +- `no_extension_access_modifier`: Use `extension Foo { public func ... }`, not `public extension Foo { func ... }` +- `let_var_whitespace`: Blank line before variable declarations +- `pattern_matching_keywords`: Move `let`/`var` outside tuples in pattern matching +- `implicitly_unwrapped_optional`: Avoid `!` optionals +- `multiline_arguments`: Multi-line function calls should have each argument on its own line + +Key thresholds: +- Cyclomatic complexity: warning at 10 +- Function parameters: max 5 +- Large tuple: warning 3, error 4 +- Type name/body max length: 50 / 400 +- `line_length` is disabled +- `force_cast` and `force_try` are warnings (not errors) + +## CI (GitHub Actions) + +Runs on PRs (`test.yml`): SwiftLint --strict → xcodebuild test. diff --git a/FuntastyKit.podspec b/FuntastyKit.podspec deleted file mode 100644 index e67f591..0000000 --- a/FuntastyKit.podspec +++ /dev/null @@ -1,29 +0,0 @@ -Pod::Spec.new do |s| - s.name = "FuntastyKit" - s.version = "2.3.0" - s.summary = "A collection of Swift utilities and protocols used in our projects." - s.description = <<-DESC - MVVM-C architecture, service holder for code injection of services - some regularly used UIKit extensions, protocols for simple initialization from XIB files, - storyboards and for handling keyboard, hairline and keyboard height constraints - DESC - s.homepage = "https://github.com/futuredapp/FuntastyKit" - s.license = { type: "MIT", file: "LICENSE" } - s.author = { "Matěj K. Jirásek" => "matej.jirasek@futured.app" } - s.social_media_url = "https://twitter.com/Futuredapps" - s.platform = :ios, "9.0" - s.swift_version = "5.0" - s.source = { :git => "https://github.com/futuredapp/FuntastyKit.git", :tag => s.version.to_s } - s.frameworks = ["Foundation", "UIKit"] - s.default_subspec = "Core" - - s.subspec "Core" do |ss| - ss.source_files = Dir["Sources/FuntastyKit/*"] - end - - s.subspec "IBInspectable" do |ss| - ss.dependency "FuntastyKit/Core" - ss.source_files = Dir["Sources/FuntastyKitIBInspectable/*"] - end -end - diff --git a/Gemfile b/Gemfile deleted file mode 100644 index abc1a45..0000000 --- a/Gemfile +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -source "https://rubygems.org" - -gem "cocoapods", "~> 1.10" diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index cdb09b9..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,93 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - CFPropertyList (3.0.2) - activesupport (5.2.4.4) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) - algoliasearch (1.27.5) - httpclient (~> 2.8, >= 2.8.3) - json (>= 1.5.1) - atomos (0.1.3) - claide (1.0.3) - cocoapods (1.10.0) - addressable (~> 2.6) - claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.10.0) - cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 1.4.0, < 2.0) - cocoapods-plugins (>= 1.0.0, < 2.0) - cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.4.0, < 2.0) - cocoapods-try (>= 1.1.0, < 2.0) - colored2 (~> 3.1) - escape (~> 0.0.4) - fourflusher (>= 2.3.0, < 3.0) - gh_inspector (~> 1.0) - molinillo (~> 0.6.6) - nap (~> 1.0) - ruby-macho (~> 1.4) - xcodeproj (>= 1.19.0, < 2.0) - cocoapods-core (1.10.0) - activesupport (> 5.0, < 6) - addressable (~> 2.6) - algoliasearch (~> 1.0) - concurrent-ruby (~> 1.1) - fuzzy_match (~> 2.0.4) - nap (~> 1.0) - netrc (~> 0.11) - public_suffix - typhoeus (~> 1.0) - cocoapods-deintegrate (1.0.4) - cocoapods-downloader (1.4.0) - cocoapods-plugins (1.0.0) - nap - cocoapods-search (1.0.0) - cocoapods-trunk (1.5.0) - nap (>= 0.8, < 2.0) - netrc (~> 0.11) - cocoapods-try (1.2.0) - colored2 (3.1.2) - concurrent-ruby (1.1.7) - escape (0.0.4) - ethon (0.12.0) - ffi (>= 1.3.0) - ffi (1.13.1) - fourflusher (2.3.1) - fuzzy_match (2.0.4) - gh_inspector (1.1.3) - httpclient (2.8.3) - i18n (1.8.5) - concurrent-ruby (~> 1.0) - json (2.3.1) - minitest (5.14.2) - molinillo (0.6.6) - nanaimo (0.3.0) - nap (1.1.0) - netrc (0.11.0) - public_suffix (4.0.6) - ruby-macho (1.4.0) - thread_safe (0.3.6) - typhoeus (1.4.0) - ethon (>= 0.9.0) - tzinfo (1.2.7) - thread_safe (~> 0.1) - xcodeproj (1.19.0) - CFPropertyList (>= 2.3.3, < 4.0) - atomos (~> 0.1.3) - claide (>= 1.0.2, < 2.0) - colored2 (~> 3.1) - nanaimo (~> 0.3.0) - -PLATFORMS - ruby - -DEPENDENCIES - cocoapods (~> 1.10) - -BUNDLED WITH - 2.1.4 diff --git a/Package.swift b/Package.swift index 81c0f75..6ca6cca 100644 --- a/Package.swift +++ b/Package.swift @@ -1,26 +1,14 @@ -// swift-tools-version:5.1 +// swift-tools-version: 6.2 import PackageDescription let package = Package( name: "FuntastyKit", - platforms: [.iOS(.v9)], + platforms: [.iOS(.v16)], products: [ - .library( - name: "FuntastyKit", - targets: ["FuntastyKit"]), - .library( - name: "FuntastyKitIBInspectable", - targets: ["FuntastyKitIBInspectable"]) + .library(name: "FuntastyKit", targets: ["FuntastyKit"]) ], targets: [ - .target( - name: "FuntastyKit", - dependencies: []), - .target( - name: "FuntastyKitIBInspectable", - dependencies: []), - .testTarget( - name: "FuntastyKitTests", - dependencies: ["FuntastyKit"]) + .target(name: "FuntastyKit"), + .testTarget(name: "FuntastyKitTests", dependencies: ["FuntastyKit"]) ] ) diff --git a/README.md b/README.md index 1ad5eee..3586bde 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,24 @@ -FuntastyKit logo +FuntastyKit logo # FuntastyKit -![Cocoapods](https://img.shields.io/cocoapods/v/FuntastyKit.svg) -![Cocoapods platforms](https://img.shields.io/cocoapods/p/FuntastyKit.svg) -![License](https://img.shields.io/cocoapods/l/FuntastyKit.svg) +![License](https://img.shields.io/github/license/futuredapp/FuntastyKit.svg) + +> Requires **Swift 6.2** / **iOS 16+** The FuntastyKit for iOS contains: - MVVM-C architecture used at [Futured](https://www.futured.app/en/), the template for this architecture can be found at https://github.com/futuredapp/MVVM-C-Templates. +- Coordinator pattern protocols for UIKit navigation (Push, Modal, Show, TabBarItem). +- Error handling with `ResolvableError` and `AlertCoordinator`. - Some regularly used UIKit extensions. -- Protocols for simple initialization from XIB files, storyboards and for handling keyboard. -- Hairline constraint for one-pixel designs. ## Installation -If you want to use CocoaPods for dependency management, add following line to your `Podfile`: - -```ruby -pod 'FuntastyKit', '~> 2.3' -``` - -If you also want to use IBInspectable extensions also add: - -```ruby -pod 'FuntastyKit', '~> 2.3', subspecs: ['IBInspectable'] -``` - When using Swift package manager add following line to your `Package.swift` file or add the repository using Xcode: ```swift -.package(url: "https://github.com/futuredapp/FuntastyKit.git", from: "2.3.0") +.package(url: "https://github.com/futuredapp/FuntastyKit.git", from: "3.0.0") ``` ## Note on the name @@ -39,10 +27,6 @@ The name of the framework comes from the name of our company before rebranding, ## Contributors -If you have any questions or issues, please contact the current maintainer: - -- Matěj K. Jirásek, matej.jirasek@futured.app - Over the years many inside and outside contributors made improvements to this library, namely: - [Petr Zvoníček](https://github.com/zvonicek) diff --git a/Sources/FuntastyKit/Architecture/Coordinator.swift b/Sources/FuntastyKit/Architecture/Coordinator.swift index 1e773ae..ecc81ea 100644 --- a/Sources/FuntastyKit/Architecture/Coordinator.swift +++ b/Sources/FuntastyKit/Architecture/Coordinator.swift @@ -1,5 +1,6 @@ import UIKit +@MainActor public protocol Coordinator { /// Triggers navigation to the corresponding controller func start() @@ -10,51 +11,56 @@ public protocol Coordinator { func stop() } -public extension Coordinator { - func stop() { - } +extension Coordinator { + public func stop() {} } +@MainActor public protocol DefaultCoordinator: Coordinator { associatedtype ViewController: UIViewController - var viewController: ViewController? { get } + var viewController: ViewController? { get } var animated: Bool { get } - var delegate: CoordinatorDelegate? { get set } + var delegate: (any CoordinatorDelegate)? { get set } } +@MainActor public protocol ConfiguringCoordinator: DefaultCoordinator { func configure(viewController: ViewController) } +@MainActor public protocol ShowCoordinator: ConfiguringCoordinator { /// When used on Split View Controller as Detail View Controller, sourceViewController should be 'weak', otherwise memory leak will emerge var sourceViewController: UIViewController? { get } var isDetail: Bool { get } } +@MainActor public protocol PushCoordinator: ConfiguringCoordinator { var navigationController: UINavigationController? { get } } +@MainActor public protocol ModalCoordinator: ConfiguringCoordinator { var sourceViewController: UIViewController { get } var destinationNavigationController: UINavigationController? { get } } +@MainActor public protocol TabBarItemCoordinator: ConfiguringCoordinator { var tabBarController: UITabBarController? { get } var destinationNavigationController: UINavigationController? { get } } -public extension DefaultCoordinator { +extension DefaultCoordinator { // default implementation if not overridden - var animated: Bool { + public var animated: Bool { true } // default implementation of nil delegate, should be overridden when needed - var delegate: CoordinatorDelegate? { + public var delegate: (any CoordinatorDelegate)? { get { nil } @@ -63,21 +69,20 @@ public extension DefaultCoordinator { } } - func stop() { + public func stop() { delegate?.willStop(in: self) delegate?.didStop(in: self) } } -public extension ShowCoordinator { - var isDetail: Bool { +extension ShowCoordinator { + public var isDetail: Bool { false } - func start() { - guard let viewController = viewController else { - return - } + public func start() { + guard let viewController else { return } + configure(viewController: viewController) if isDetail { sourceViewController?.showDetailViewController(viewController, sender: nil) @@ -87,43 +92,39 @@ public extension ShowCoordinator { } } -public extension PushCoordinator { - func start() { - guard let viewController = viewController else { - return - } +extension PushCoordinator { + public func start() { + guard let viewController else { return } configure(viewController: viewController) navigationController?.pushViewController(viewController, animated: animated) } - func stop() { + public func stop() { delegate?.willStop(in: self) navigationController?.popViewController(animated: animated) delegate?.didStop(in: self) } } -public extension ModalCoordinator { - var destinationNavigationController: UINavigationController? { nil } +extension ModalCoordinator { + public var destinationNavigationController: UINavigationController? { nil } - func start() { - guard let viewController = viewController else { - return - } + public func start() { + guard let viewController else { return } configure(viewController: viewController) - if let destinationNavigationController = destinationNavigationController { + if let destinationNavigationController { // wrapper navigation controller given, present it - sourceViewController.present(destinationNavigationController, animated: animated, completion: nil) + sourceViewController.present(destinationNavigationController, animated: animated) } else { // no wrapper navigation controller given, present actual controller - sourceViewController.present(viewController, animated: animated, completion: nil) + sourceViewController.present(viewController, animated: animated) } } - func stop() { + public func stop() { delegate?.willStop(in: self) viewController?.dismiss(animated: animated) { self.delegate?.didStop(in: self) @@ -131,13 +132,12 @@ public extension ModalCoordinator { } } -public extension TabBarItemCoordinator { - var destinationNavigationController: UINavigationController? { nil } +extension TabBarItemCoordinator { + public var destinationNavigationController: UINavigationController? { nil } + + public func start() { + guard let viewController else { return } - func start() { - guard let viewController = viewController else { - return - } configure(viewController: viewController) var viewControllers = tabBarController?.viewControllers ?? [] @@ -146,11 +146,10 @@ public extension TabBarItemCoordinator { tabBarController?.setViewControllers(viewControllers, animated: animated) } - func stop() { + public func stop() { delegate?.willStop(in: self) - guard let viewController = viewController, let viewControllers = tabBarController?.viewControllers else { - return - } + + guard let viewController, let viewControllers = tabBarController?.viewControllers else { return } var mutableViewControllers = viewControllers if let index = mutableViewControllers.firstIndex(of: destinationNavigationController ?? viewController) { @@ -162,7 +161,8 @@ public extension TabBarItemCoordinator { } } +@MainActor public protocol CoordinatorDelegate: AnyObject { - func willStop(in coordinator: Coordinator) - func didStop(in coordinator: Coordinator) + func willStop(in coordinator: any Coordinator) + func didStop(in coordinator: any Coordinator) } diff --git a/Sources/FuntastyKit/AutoLayout/HairlineLayoutConstraint.swift b/Sources/FuntastyKit/AutoLayout/HairlineLayoutConstraint.swift deleted file mode 100644 index 351a5c3..0000000 --- a/Sources/FuntastyKit/AutoLayout/HairlineLayoutConstraint.swift +++ /dev/null @@ -1,10 +0,0 @@ -import UIKit - -/// Useful to ensure 1px size on retina displays -public class HairlineLayoutConstraint: NSLayoutConstraint { - - override public func awakeFromNib() { - super.awakeFromNib() - self.constant /= UIScreen.main.scale - } -} diff --git a/Sources/FuntastyKit/AutoLayout/KeyboardHeightConstraint.swift b/Sources/FuntastyKit/AutoLayout/KeyboardHeightConstraint.swift deleted file mode 100644 index 2dee47b..0000000 --- a/Sources/FuntastyKit/AutoLayout/KeyboardHeightConstraint.swift +++ /dev/null @@ -1,57 +0,0 @@ -import UIKit - -public final class KeyboardHeightConstraint: NSLayoutConstraint { - - override public func awakeFromNib() { - super.awakeFromNib() - - let center: NotificationCenter = .default - center.addObserver(self, selector: #selector(keyboardWillChange), name: UIResponder.keyboardWillHideNotification, object: nil) - center.addObserver(self, selector: #selector(keyboardWillChange), name: UIResponder.keyboardWillShowNotification, object: nil) - } - - private var superview: UIView? { - (secondItem as? UIView)?.superview - } - - @objc - private func keyboardWillChange(_ notification: Notification) { - guard let userInfo = notification.userInfo else { - return - } - - let insetHeight = (notification.name == UIResponder.keyboardWillHideNotification) ? 0.0 : height(for: userInfo) - inset - - superview?.layoutIfNeeded() - UIView.animate(withDuration: duration(from: userInfo), delay: 0, options: options(from: userInfo), animations: { - self.constant = insetHeight - self.superview?.layoutIfNeeded() - }, completion: nil) - } - - private var inset: CGFloat { - if #available(iOS 11.0, *) { - return superview?.safeAreaInsets.bottom ?? 0.0 - } - return 0.0 - } - - private func height(for userInfo: [AnyHashable: Any]) -> CGFloat { - userInfo[UIResponder.keyboardFrameEndUserInfoKey] - .flatMap { $0 as? NSValue } - .map { $0.cgRectValue.height } ?? 0.0 - } - - private func duration(from userInfo: [AnyHashable: Any]) -> Double { - userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] - .flatMap { $0 as? NSNumber } - .map { $0.doubleValue } ?? 0.0 - } - - private func options(from userInfo: [AnyHashable: Any]) -> UIView.AnimationOptions { - userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] - .flatMap { $0 as? NSNumber } - .map { $0.uintValue << 16 } - .map(UIView.AnimationOptions.init) ?? UIView.AnimationOptions() - } -} diff --git a/Sources/FuntastyKit/Errors/AlertCoordinator.swift b/Sources/FuntastyKit/Errors/AlertCoordinator.swift index d1f7df1..0e91459 100644 --- a/Sources/FuntastyKit/Errors/AlertCoordinator.swift +++ b/Sources/FuntastyKit/Errors/AlertCoordinator.swift @@ -3,34 +3,35 @@ import UIKit public class AlertCoordinator: DefaultCoordinator { public typealias ViewController = UIAlertController - public enum Source { + public enum Source: Sendable { case button(UIBarButtonItem) case view(UIView) } - public enum Style { + public enum Style: Sendable { case alert case actionSheet(source: Source?) var controllerStyle: UIAlertController.Style { switch self { case .alert: - return .alert + .alert case .actionSheet: - return .actionSheet + .actionSheet } } } + @MainActor enum InputType { - case error(Error) + case error(any Error) case custom(title: String?, message: String?, actions: [ErrorAction]?) func alertController(preferredStyle: Style = .alert) -> UIAlertController { switch self { case .error(let error): return UIAlertController(error: error, preferredStyle: preferredStyle.controllerStyle) - case .custom(let title, let message, let actions): + case let .custom(title, message, actions): let alert = UIAlertController(title: title, message: message, preferredStyle: preferredStyle.controllerStyle) (actions ?? [ErrorAction(title: NSLocalizedString("OK", comment: "OK"))]) .forEach { action in @@ -47,14 +48,14 @@ public class AlertCoordinator: DefaultCoordinator { let parentViewController: UIViewController public weak var viewController: UIAlertController? - public weak var delegate: CoordinatorDelegate? + public weak var delegate: (any CoordinatorDelegate)? private var type: InputType private var preferredStyle: Style // MARK: - Inits - public init(parent: UIViewController, error: Error, preferredStyle: Style = .alert) { + public init(parent: UIViewController, error: any Error, preferredStyle: Style = .alert) { self.parentViewController = parent self.type = .error(error) self.preferredStyle = preferredStyle @@ -76,7 +77,7 @@ public class AlertCoordinator: DefaultCoordinator { alert.popoverPresentationController?.sourceView = view } } - parentViewController.present(alert, animated: animated, completion: nil) + parentViewController.present(alert, animated: animated) viewController = alert } @@ -88,39 +89,36 @@ public class AlertCoordinator: DefaultCoordinator { } } -public extension ErrorAction { - func alertStyle() -> UIAlertAction.Style { - switch self.style { +@MainActor +extension ErrorAction { + public func alertStyle() -> UIAlertAction.Style { + switch style { case .cancel: - return .cancel + .cancel case .destructive: - return .destructive + .destructive default: - return .default + .default } } - func alertAction() -> UIAlertAction { + public func alertAction() -> UIAlertAction { UIAlertAction(title: self.title, style: self.alertStyle()) { _ in self.action?() } } } -public extension DefaultCoordinator { - func showAlert(for error: Error, preferredStyle: AlertCoordinator.Style = .alert) { - guard let viewController = self.viewController else { - return - } +extension DefaultCoordinator { + public func showAlert(for error: any Error, preferredStyle: AlertCoordinator.Style = .alert) { + guard let viewController else { return } let alertCoordinator = AlertCoordinator(parent: viewController, error: error, preferredStyle: preferredStyle) alertCoordinator.start() } - func showAlert(title: String?, message: String?, actions: [ErrorAction]? = nil, preferredStyle: AlertCoordinator.Style = .alert) { - guard let viewController = self.viewController else { - return - } + public func showAlert(title: String?, message: String?, actions: [ErrorAction]? = nil, preferredStyle: AlertCoordinator.Style = .alert) { + guard let viewController else { return } let alertCoordinator = AlertCoordinator(parent: viewController, title: title, message: message, actions: actions, preferredStyle: preferredStyle) alertCoordinator.start() diff --git a/Sources/FuntastyKit/Errors/Errors.swift b/Sources/FuntastyKit/Errors/Errors.swift index f97734c..b43c9d3 100644 --- a/Sources/FuntastyKit/Errors/Errors.swift +++ b/Sources/FuntastyKit/Errors/Errors.swift @@ -4,19 +4,19 @@ public protocol ResolvableError: LocalizedError { var actions: [ErrorAction] { get } } -public struct ErrorAction { - public enum Style: Int { +public struct ErrorAction: Sendable { + public enum Style: Int, Sendable { case `default` case cancel case destructive case preferred } - public typealias ErrorHandler = () -> Void + public typealias ErrorHandler = @MainActor @Sendable () -> Void - let title: String - var action: ErrorHandler? - var style: Style + public let title: String + public var action: ErrorHandler? + public var style: Style public init(title: String, style: Style = .default, action: ErrorHandler? = nil) { self.title = title diff --git a/Sources/FuntastyKit/Errors/UIAlertController+Error.swift b/Sources/FuntastyKit/Errors/UIAlertController+Error.swift index 588233b..77a929d 100644 --- a/Sources/FuntastyKit/Errors/UIAlertController+Error.swift +++ b/Sources/FuntastyKit/Errors/UIAlertController+Error.swift @@ -1,11 +1,13 @@ import UIKit -public extension UIAlertController { - convenience init(error: Error, preferredStyle: UIAlertController.Style = .alert) { - self.init(title: UIAlertController.alertTitle(error: error), - message: UIAlertController.alertMessage(error: error), - preferredStyle: preferredStyle) - if let error = error as? ResolvableError { +extension UIAlertController { + public convenience init(error: any Error, preferredStyle: UIAlertController.Style = .alert) { + self.init( + title: UIAlertController.alertTitle(error: error), + message: UIAlertController.alertMessage(error: error), + preferredStyle: preferredStyle + ) + if let error = error as? any ResolvableError { error.actions.map { $0.alertAction() }.forEach(self.addAction) if !error.actions.isEmpty { return @@ -14,21 +16,21 @@ public extension UIAlertController { self.addAction(UIAlertAction(title: UIAlertController.okButtonText, style: .default)) } - private static func alertTitle(error: Error) -> String { + private static func alertTitle(error: any Error) -> String { switch error { - case let error as LocalizedError: - return error.errorDescription ?? defaultErrorTitle + case let error as any LocalizedError: + error.errorDescription ?? defaultErrorTitle default: - return defaultErrorTitle + defaultErrorTitle } } - private static func alertMessage(error: Error) -> String { + private static func alertMessage(error: any Error) -> String { switch error { - case let error as LocalizedError: - return error.failureReason ?? error.localizedDescription + case let error as any LocalizedError: + error.failureReason ?? error.localizedDescription default: - return error.localizedDescription + error.localizedDescription } } diff --git a/Sources/FuntastyKit/Extensions/UITableView+Extensions.swift b/Sources/FuntastyKit/Extensions/UITableView+Extensions.swift index f113465..923aafd 100644 --- a/Sources/FuntastyKit/Extensions/UITableView+Extensions.swift +++ b/Sources/FuntastyKit/Extensions/UITableView+Extensions.swift @@ -1,18 +1,22 @@ -import Foundation import UIKit -public extension UITableView { +extension UIView { + public static var nibName: String { + String(describing: self) + } +} - func registerNib(for cellClass: T.Type) { +extension UITableView { + public func registerNib(for cellClass: T.Type) { let nib = UINib(nibName: cellClass.nibName, bundle: nil) register(nib, forCellReuseIdentifier: cellClass.nibName) } - func registerCellClass(_ type: T.Type) { + public func registerCellClass(_ type: T.Type) { register(type, forCellReuseIdentifier: type.nibName) } - func dequeueReusableCell(withType type: T.Type) -> T { + public func dequeueReusableCell(withType type: T.Type) -> T { if let cell = dequeueReusableCell(withIdentifier: type.nibName) as? T { return cell } else { diff --git a/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift b/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift index 65263fa..f14930b 100644 --- a/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift +++ b/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift @@ -1,7 +1,9 @@ import UIKit +@MainActor public protocol Deselectable { var indexPathsForSelectedItems: [IndexPath]? { get } + func selectItem(at indexPath: IndexPath?, animated: Bool) func deselectItem(at indexPath: IndexPath, animated: Bool) } @@ -29,7 +31,7 @@ extension UICollectionView: Deselectable { } } -public extension UIViewController { +extension UIViewController { /// Smoothly deselect selected rows in a table view during an animated /// transition, and intelligently reselect those rows if the interactive @@ -38,7 +40,7 @@ public extension UIViewController { /// /// - parameter deselectable: The (de)selectable view in which to perform deselection/reselection. @nonobjc - func smoothlyDeselectItems(on deselectable: Deselectable?) { + public func smoothlyDeselectItems(on deselectable: (any Deselectable)?) { let selectedIndexPaths = deselectable?.indexPathsForSelectedItems ?? [] if let coordinator = transitionCoordinator { @@ -78,17 +80,19 @@ public extension UIViewController { } private func switchSelectedItemsState( - on deselectable: Deselectable?, + on deselectable: (any Deselectable)?, selectedIndexPaths: [IndexPath], shouldBeSelected: Bool, animated: Bool ) { - guard let deselectable = deselectable else { - return - } + guard let deselectable else { return } + selectedIndexPaths.forEach { - let switchSelectionFunc = shouldBeSelected ? deselectable.selectItem : deselectable.deselectItem - switchSelectionFunc($0, animated) + if shouldBeSelected { + deselectable.selectItem(at: $0, animated: animated) + } else { + deselectable.deselectItem(at: $0, animated: animated) + } } } } diff --git a/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift b/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift index f108a31..5f7e4d3 100644 --- a/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift +++ b/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift @@ -3,7 +3,7 @@ import UIKit /** * UIWindow extension for setting the rootViewController on a UIWindow instance in a safe and animatable way. */ -public extension UIWindow { +extension UIWindow { /** Set the rootViewController on this UIWindow instance. @@ -13,7 +13,13 @@ public extension UIWindow { - parameter completion: Completion block to be invoked after the transition finishes */ @nonobjc - func setRootViewController(_ viewController: UIViewController, animated: Bool, duration: TimeInterval = 0.3, options: UIView.AnimationOptions = .transitionCrossDissolve, completion: ((Bool) -> Void)? = nil) { + public func setRootViewController( + _ viewController: UIViewController, + animated: Bool, + duration: TimeInterval = 0.3, + options: UIView.AnimationOptions = .transitionCrossDissolve, + completion: ((Bool) -> Void)? = nil + ) { rootViewController = viewController if animated { UIView.transition(with: self, duration: duration, options: options, animations: nil, completion: completion) diff --git a/Sources/FuntastyKit/Protocols/Nibable.swift b/Sources/FuntastyKit/Protocols/Nibable.swift deleted file mode 100644 index fe71c3d..0000000 --- a/Sources/FuntastyKit/Protocols/Nibable.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Nibable.swift -// FuntastyKit -// -// Created by Matěj Jirásek on 07/10/2016. -// Copyright © 2016 FUNTASTY Digital s.r.o. All rights reserved. -// - -import UIKit - -public extension UIView { - - static var nibName: String { - String(describing: self) - } -} - -public protocol Nibable { - static var nib: UINib { get } -} - -public extension Nibable where Self: UIView { - - static var nib: UINib { - UINib(nibName: Self.nibName, bundle: nil) - } - - init(owner: AnyObject? = nil) { - let views = Self.nib.instantiate(withOwner: owner, options: [:]) - self = views.lazy.compactMap { $0 as? Self }.first ?? { () -> Self in - fatalError("Nib for class \(Self.nibName) could not be loaded!") - }() - } -} diff --git a/Sources/FuntastyKit/Protocols/StoryboardType.swift b/Sources/FuntastyKit/Protocols/StoryboardType.swift deleted file mode 100644 index dcb754d..0000000 --- a/Sources/FuntastyKit/Protocols/StoryboardType.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// StoryboardType.swift -// FuntastyKit -// -// Created by Aleš Kocur on 21.05.16. -// Copyright © 2016 The Funtasty. All rights reserved. -// - -import Foundation -import UIKit - -public protocol StoryboardType { - static var name: String { get } -} - -public struct StoryboardReference { - - private let id: String - private let bundle: Bundle? - - public init(id: String, bundle: Bundle? = nil) { - self.bundle = bundle - self.id = id - } - - public func instantiate() -> T { - if let controller = UIStoryboard(name: S.name, bundle: bundle).instantiateViewController(withIdentifier: id) as? T { - return controller - } else { - fatalError("Instantiated controller with \(id) has different type than expected!") - } - } -} diff --git a/Sources/FuntastyKitIBInspectable/UIView+Extensions.swift b/Sources/FuntastyKitIBInspectable/UIView+Extensions.swift deleted file mode 100644 index d64f005..0000000 --- a/Sources/FuntastyKitIBInspectable/UIView+Extensions.swift +++ /dev/null @@ -1,74 +0,0 @@ -import UIKit - -@IBDesignable -extension UIView { - - // MARK: - Corner radius - - @IBInspectable open var cornerRadius: CGFloat { - get { - layer.cornerRadius - } - set { - layer.cornerRadius = newValue - } - } - - // MARK: - Shadow - - @IBInspectable open var shadowColor: UIColor? { - get { - layer.shadowColor.flatMap(UIColor.init) - } - set { - layer.shadowColor = newValue?.cgColor - } - } - - @IBInspectable open var shadowOffset: CGSize { - get { - layer.shadowOffset - } - set { - layer.shadowOffset = newValue - } - } - - @IBInspectable open var shadowRadius: CGFloat { - get { - layer.shadowRadius - } - set { - layer.shadowRadius = newValue - } - } - - @IBInspectable open var shadowOpacity: Float { - get { - layer.shadowOpacity - } - set { - layer.shadowOpacity = newValue - } - } - - // MARK: - Border - - @IBInspectable open var borderWidth: CGFloat { - get { - layer.borderWidth - } - set { - layer.borderWidth = newValue - } - } - - @IBInspectable open var borderColor: UIColor? { - get { - layer.borderColor.flatMap(UIColor.init) - } - set { - layer.borderColor = newValue?.cgColor - } - } -} diff --git a/Tests/FuntastyKitTests/ExampleScene.swift b/Tests/FuntastyKitTests/ExampleScene.swift index 7601278..1f0989f 100644 --- a/Tests/FuntastyKitTests/ExampleScene.swift +++ b/Tests/FuntastyKitTests/ExampleScene.swift @@ -6,12 +6,15 @@ struct Model { // MARK: - Controller +@MainActor protocol ExampleViewControllerInput: AnyObject { // TO-DO: Place your presenting methods here } +@MainActor final class ExampleViewController: UIViewController { - var viewModel: ExampleViewModel! + + var viewModel: ExampleViewModel? } extension ExampleViewController: ExampleViewControllerInput { @@ -20,13 +23,14 @@ extension ExampleViewController: ExampleViewControllerInput { // MARK: - View model +@MainActor final class ExampleViewModel { - private weak var viewController: ExampleViewControllerInput? - private let coordinator: ExampleCoordinatorInput + private weak var viewController: (any ExampleViewControllerInput)? + private let coordinator: any ExampleCoordinatorInput private let model: Model - init(model: Model, coordinator: ExampleCoordinatorInput, viewController: ExampleViewControllerInput) { + init(model: Model, coordinator: any ExampleCoordinatorInput, viewController: any ExampleViewControllerInput) { self.model = model self.coordinator = coordinator self.viewController = viewController @@ -35,10 +39,12 @@ final class ExampleViewModel { // MARK: - Coordinator +@MainActor protocol ExampleCoordinatorInput: Coordinator { // TO-DO: Place your navigation methods here } +@MainActor final class ExampleCoordinator: ModalCoordinator { var sourceViewController: UIViewController diff --git a/Tests/FuntastyKitTests/FuntastyKitTests.swift b/Tests/FuntastyKitTests/FuntastyKitTests.swift index 108ffc4..9c16994 100644 --- a/Tests/FuntastyKitTests/FuntastyKitTests.swift +++ b/Tests/FuntastyKitTests/FuntastyKitTests.swift @@ -1,16 +1,24 @@ -import Foundation import FuntastyKit -import XCTest +import Testing +import UIKit -final class FuntastyKitTests: XCTestCase { +@Suite +@MainActor +struct FuntastyKitTests { - func testArchitecture() { + @Test + func architecture() { let model = Model() + let window = UIWindow() + let viewController = UIViewController() - UIApplication.shared.keyWindow?.rootViewController = viewController + window.rootViewController = viewController + window.makeKeyAndVisible() let coordinator = ExampleCoordinator(from: viewController, model: model) coordinator.start() + + #expect(coordinator.viewController?.viewModel != nil) } }