diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..95c4320
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.DS_Store
+/.build
+/Packages
+/*.xcodeproj
+xcuserdata/
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/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..fd91cc4
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,43 @@
+{
+ "object": {
+ "pins": [
+ {
+ "package": "CombineEx",
+ "repositoryURL": "https://github.com/RocketLaunchpad/CombineEx.git",
+ "state": {
+ "branch": null,
+ "revision": "f77496af0df6563525ae3e432d4a3fa894a6b4e1",
+ "version": "1.0.0"
+ }
+ },
+ {
+ "package": "FoundationEx",
+ "repositoryURL": "https://github.com/RocketLaunchpad/FoundationEx.git",
+ "state": {
+ "branch": null,
+ "revision": "1e29902f7c26fff9828c3f488a314786fc7756ff",
+ "version": "1.0.0"
+ }
+ },
+ {
+ "package": "ReducerArchitecture",
+ "repositoryURL": "https://github.com/RocketLaunchpad/ReducerArchitecture.git",
+ "state": {
+ "branch": null,
+ "revision": "99d9d0908a2e1558b34232a88f88acd870838148",
+ "version": "1.1.1"
+ }
+ },
+ {
+ "package": "Tagged",
+ "repositoryURL": "https://github.com/pointfreeco/swift-tagged.git",
+ "state": {
+ "branch": null,
+ "revision": "592e1eb4255c571ebffc29955011e352217501b4",
+ "version": "0.5.0"
+ }
+ }
+ ]
+ },
+ "version": 1
+}
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..1bdc3ff
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,32 @@
+// swift-tools-version:5.3
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "UIKitEx",
+ platforms: [
+ .macOS(.v10_15), .iOS(.v13), .tvOS(.v13)
+ ],
+ products: [
+ .library(
+ name: "UIKitEx",
+ targets: ["UIKitEx"]),
+ ],
+ dependencies: [
+ .package(name: "FoundationEx", url: "https://github.com/RocketLaunchpad/FoundationEx.git", from: "1.0.0"),
+ .package(name: "CombineEx", url: "https://github.com/RocketLaunchpad/CombineEx.git", from: "1.0.0"),
+ .package(name: "ReducerArchitecture", url: "https://github.com/RocketLaunchpad/ReducerArchitecture.git", from: "1.0.0"),
+ // .package(name: "Functional", url: "https://github.com/RocketLaunchpad/Functional.git", from: "1.0.0"),
+ ],
+ targets: [
+ .target(
+ name: "UIKitEx",
+ dependencies: ["FoundationEx", "CombineEx", "ReducerArchitecture"]
+ ),
+ .testTarget(
+ name: "UIKitExTests",
+ dependencies: ["UIKitEx"]
+ )
+ ]
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b95dc13
--- /dev/null
+++ b/README.md
@@ -0,0 +1,3 @@
+# UIKitEx
+
+A description of this package.
diff --git a/Sources/UIKitEx/AppFlow/AnyFlow.swift b/Sources/UIKitEx/AppFlow/AnyFlow.swift
new file mode 100644
index 0000000..9291c3b
--- /dev/null
+++ b/Sources/UIKitEx/AppFlow/AnyFlow.swift
@@ -0,0 +1,117 @@
+//
+// AnyFlow.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 2/8/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+import Combine
+import CombineEx
+
+public struct AnyFlow {
+ public enum Mode {
+ case presentation
+ case inPlace
+ case embedded
+
+ public var isPresentation: Bool {
+ switch self {
+ case .presentation:
+ return true
+ default:
+ return false
+ }
+ }
+
+ public var canAddPromptAtEnd: Bool {
+ switch self {
+ case .presentation, .inPlace:
+ return true
+ case .embedded:
+ return false
+ }
+ }
+ }
+
+ public let nc: UINavigationController
+ public var start: () -> AppFlow.CancellableFinshedActionPublisher
+ public var finish: () -> AppFlow.CancellableFinshedActionPublisher
+ public var restart: () -> AppFlow.CancellableFinshedActionPublisher
+
+ public init(
+ on _vc: UINavigationController,
+ mode: Mode,
+ presentationContainerType: UINavigationController.Type,
+ delayFinish: Bool = false,
+ animateStart: Bool = true
+ ) {
+ switch mode {
+ case .presentation:
+ weak var vc = _vc
+ let nc = presentationContainerType.init()
+ self.nc = nc
+
+ start = { [weak vc] in
+ guard let vc = vc else { return AppFlow.cancel() }
+ vc.present(nc, animated: animateStart)
+ return AppFlow.cancellableFinishedAction
+ }
+
+ finish = { [weak vc] in
+ guard let vc = vc else { return AppFlow.cancel() }
+ vc.dismiss(animated: true)
+ let res = delayFinish ? AppFlow.standardPresentationDelay : AppFlow.finishedAction
+ return res.addUserCanCancel().eraseType()
+ }
+
+ restart = {
+ AppFlow.popToRoot(of: nc).addUserCanCancel().eraseType()
+ }
+
+ case .inPlace, .embedded:
+ assert(_vc.topViewController != nil)
+ weak var nc = _vc
+ weak var startVC = AppFlow.getStartVC(on: _vc)
+ self.nc = _vc
+
+ start = {
+ AppFlow.cancellableNoAction
+ }
+
+ if mode == .inPlace {
+ finish = { [weak startVC] in
+ guard let startVC = startVC else {
+ assertionFailure()
+ return AppFlow.cancellableNoAction
+ }
+ let delayTime = delayFinish ? Styling.standardNavigationDelay : 0
+ return AppFlow.pop(to: startVC, delayFor: delayTime).addUserCanCancel().eraseType()
+ }
+ }
+ else {
+ finish = {
+ return AppFlow.cancellableNoAction
+ }
+ }
+
+ restart = { [weak startVC, weak nc] in
+ guard let nc = nc else { return AppFlow.cancel() }
+ guard let startVC = startVC else {
+ assertionFailure()
+ return AppFlow.cancel()
+ }
+ guard let flowFirstVC = AppFlow.vcAfter(startVC, on: nc) else {
+ assertionFailure()
+ return AppFlow.cancel()
+ }
+ return AppFlow.pop(to: flowFirstVC).addUserCanCancel().eraseType()
+ }
+ }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/AppFlow/AppFlow.swift b/Sources/UIKitEx/AppFlow/AppFlow.swift
new file mode 100644
index 0000000..66fbcea
--- /dev/null
+++ b/Sources/UIKitEx/AppFlow/AppFlow.swift
@@ -0,0 +1,68 @@
+//
+// AppFlow.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 7/19/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import Combine
+import CombineEx
+import UIKit
+
+public protocol SingleValueFlow {
+ associatedtype Value
+ associatedtype AsyncValue: SingleValuePublisher where AsyncValue.Output == Value
+ func run() -> AsyncValue
+}
+
+public protocol ReplaceableInAppFlow: UIViewController {}
+
+public protocol AppFlowViewController: UIViewController {
+ func configureBackButton(_ nc: UINavigationController)
+ func cancel()
+}
+
+public typealias AsyncValueUIasFlowVC = AsyncValueUI & AppFlowViewController
+
+public enum AppFlow {
+ public typealias FinshedActionPublisher = AnySingleValuePublisher
+ public typealias CancellableFinshedActionPublisher = AnySingleValuePublisher
+ public static let finishedAction: FinshedActionPublisher = Just(()).eraseType()
+ public static let cancellableFinishedAction: CancellableFinshedActionPublisher = Just(()).addUserCanCancel().eraseType()
+ public static let noAction = Just(()).eraseType()
+ public static let cancellableNoAction = Just(()).addUserCanCancel().eraseType()
+
+ public static func start() -> Just {
+ Just(())
+ }
+
+ public static func never() -> AnySingleValuePublisher {
+ Combine.Empty(completeImmediately: false).eraseType()
+ }
+
+ public static func cancel() -> AnySingleValuePublisher {
+ Fail(error: .cancel).eraseType()
+ }
+
+ public static func cancel() -> AnyPublisher {
+ Fail(error: .cancel).eraseToAnyPublisher()
+ }
+}
+
+public extension Publisher where Failure == Never {
+ func addUserCanCancel() -> Publishers.MapError {
+ addErrorType(Cancel.self)
+ }
+}
+
+public extension Publisher where Output == Void, Failure == Cancel {
+ func catchCancelAsVoid() -> Publishers.ReplaceError {
+ replaceError(with: ())
+ }
+}
+
+#endif
+
diff --git a/Sources/UIKitEx/AppFlow/AppFlowNavigation.swift b/Sources/UIKitEx/AppFlow/AppFlowNavigation.swift
new file mode 100644
index 0000000..00c3cb3
--- /dev/null
+++ b/Sources/UIKitEx/AppFlow/AppFlowNavigation.swift
@@ -0,0 +1,89 @@
+//
+// AppFlowNavigation.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 2/27/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import Combine
+import CombineEx
+import UIKit
+
+public extension AppFlow {
+ static func delay(for interval: DispatchQueue.SchedulerTimeType.Stride) -> AnySingleValuePublisher {
+ Just(()).delay(for: interval, scheduler: DispatchQueue.main).eraseType()
+ }
+
+ static let standardPresentationDelay = delay(for: .seconds(Styling.standardPresentationDelay))
+
+ static func getStartVC(on nc: UINavigationController) -> UIViewController {
+ if let startVC = nc.viewControllers.reversed().first(where: { vc in
+ (vc is AppFlowViewController) && !(vc is ReplaceableInAppFlow)
+ })
+ .flatMap({ $0 as? AppFlowViewController }) {
+ return startVC
+ }
+ else if let vc = nc.topViewController {
+ return vc
+ }
+ else {
+ fatalError()
+ }
+ }
+
+ static func vcAfter(_ vc: UIViewController, on nc: UINavigationController) -> UIViewController? {
+ zip(nc.viewControllers, nc.viewControllers.dropFirst()).first { $0.0 == vc }?.1
+ }
+
+ static func vcBefore(_ vc: UIViewController, on nc: UINavigationController) -> UIViewController? {
+ zip(nc.viewControllers, nc.viewControllers.dropFirst()).first { $0.1 == vc }?.0
+ }
+
+ static func show(_ vc: UI, on nc: UINavigationController) -> AnyPublisher {
+ showVC(vc, on: nc)
+ vc.configureBackButton(nc)
+ return vc.value
+ }
+
+ static func showVC(_ vc: UIViewController, on nc: UINavigationController) {
+ if let topVC = nc.topViewController as? ReplaceableInAppFlow {
+ topVC.replace(by: vc)
+ }
+ else {
+ let animated = !nc.viewControllers.isEmpty
+ nc.pushViewController(vc, animated: animated)
+ }
+ }
+
+ static func pop(to vc: UIViewController, delayFor time: TimeInterval = 0) -> FinshedActionPublisher {
+ guard let nc = vc.navigationController else {
+ assertionFailure()
+ return finishedAction
+ }
+
+ nc.viewControllers
+ .reversed()
+ .prefix(while: {$0 != vc})
+ .forEach { ($0 as? BasicViewController)?.endValue = .fromCode }
+
+ nc.popToViewController(vc, animated: true)
+ if time > 0 {
+ return finishedAction
+ .delay(for: .seconds(time), scheduler: DispatchQueue.main)
+ .eraseType()
+ }
+ else {
+ return finishedAction
+ }
+ }
+
+ static func popToRoot(of nc: UINavigationController, delayFor time: TimeInterval = 0) -> FinshedActionPublisher {
+ guard let firstVC = nc.viewControllers.first else { return finishedAction }
+ return pop(to: firstVC, delayFor: time)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/AppFlow/AppFlowRun.swift b/Sources/UIKitEx/AppFlow/AppFlowRun.swift
new file mode 100644
index 0000000..8c5a6d9
--- /dev/null
+++ b/Sources/UIKitEx/AppFlow/AppFlowRun.swift
@@ -0,0 +1,106 @@
+//
+// AppFlowRun.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 2/27/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+import Foundation
+import Combine
+import CombineEx
+import UIKit
+
+public struct ActionInfo {
+ public init(description: String, action: @escaping () -> AnySingleValuePublisher) {
+ self.description = description
+ self.action = action
+ }
+
+ public let description: String
+ public let action: () -> AnySingleValuePublisher
+}
+
+public protocol AsyncTaskUI: AppFlowViewController, ReplaceableInAppFlow, ReducerArchitectureVC {
+ associatedtype E: Error
+ static func make(message: String, asyncTask: @escaping () -> AnySingleValuePublisher) -> Self
+}
+
+public protocol ActionPickerUI: UIViewController {
+ associatedtype T
+ var done: AnySingleValuePublisher, Never> { get }
+ static func make(title: String, description: String, actions: [ActionInfo]) -> Self
+}
+
+public extension AppFlow {
+ static func run(
+ message: String,
+ asyncTask: @escaping () -> AnySingleValuePublisher,
+ containerType: C.Type,
+ minDelay: TimeInterval = 0.4,
+ on nc: UINavigationController
+ )
+ -> AnySingleValuePublisher
+ where C.Store.PublishedValue == T, C.E == E
+ {
+ let asyncTaskVC = containerType.make(message: message, asyncTask: asyncTask)
+ showVC(asyncTaskVC, on: nc)
+ let timer = AppFlow.delay(for: .seconds(minDelay)).addUserCanCancel()
+ return asyncTaskVC.value.first().zip(timer).map { $0.0 }.eraseType()
+ }
+
+ static func run(
+ message: String,
+ asyncTask: @escaping () -> AnySingleValuePublisher,
+ containerType: C.Type,
+ on nc: UINavigationController
+ )
+ -> AnySingleValuePublisher
+ where C.Store.PublishedValue == T
+ {
+ run(
+ message: message,
+ asyncTask: { asyncTask().addErrorType(C.E.self).eraseType() },
+ containerType: containerType,
+ on: nc
+ )
+ .assertNoFailure()
+ .eraseType()
+ }
+
+ static func run(
+ task: @escaping () -> CachedSingleValuePublisher,
+ wrapIfNotCached wrapper: (@escaping () -> AnySingleValuePublisher) -> AnySingleValuePublisher
+ )
+ -> AnySingleValuePublisher
+ {
+ if let value = task().cachedValue {
+ return Just(value).addErrorType(WrappedError.self).eraseType()
+ }
+
+ return wrapper { task().unwrap() }
+ }
+
+ static func runSelectedAction(
+ title: String,
+ description: String,
+ actions: [ActionInfo],
+ pickerType: C.Type,
+ selectOn vc: UIViewController
+ )
+ -> AnySingleValuePublisher
+ where C.T == T
+ {
+ let pickerVC = C.make(title: title, description: description, actions: actions)
+
+ vc.present(pickerVC, animated: true)
+ return pickerVC.done
+ .sideEffect { [weak vc] _ in
+ guard let vc = vc else { return }
+ (vc as? BasicViewController)?.endValue = .fromCode
+ vc.dismiss(animated: true)
+ }
+ .flatMapLatest { $0.action() }
+ .eraseType()
+ }
+}
diff --git a/Sources/UIKitEx/AppFlow/AsyncValueUI.swift b/Sources/UIKitEx/AppFlow/AsyncValueUI.swift
new file mode 100644
index 0000000..08ff776
--- /dev/null
+++ b/Sources/UIKitEx/AppFlow/AsyncValueUI.swift
@@ -0,0 +1,15 @@
+//
+// AsyncValueUI.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 03/30/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+import Combine
+import CombineEx
+
+public protocol AsyncValueUI {
+ associatedtype Value
+ var value: AnyPublisher { get }
+}
diff --git a/Sources/UIKitEx/Components/AnyComponent.swift b/Sources/UIKitEx/Components/AnyComponent.swift
new file mode 100644
index 0000000..9a63ebb
--- /dev/null
+++ b/Sources/UIKitEx/Components/AnyComponent.swift
@@ -0,0 +1,79 @@
+//
+// AnyComponent.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 6/23/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+import UIKit
+
+public protocol AnyUIKitComponent: UIView {
+ associatedtype Style
+ var currentStyle: Style? { get set }
+ var currentTraits: Styling.Traits? { get set }
+
+ static func style(_ traits: Styling.Traits) -> Style?
+
+ /// Changes styling properties of the view and its subviews.
+ /// This method may be called more than once, so if it adds any subviews,
+ /// shadows, layers, etc., it should first revert the possible changes
+ /// from the previous invocation.
+ func applyStyle(_ style: Style)
+
+ /// Changes the constraints according to the style and traits.
+ /// This method may be called more than once, so if it adds constraints,
+ /// it should first revert the possible changes from the previous invocation.
+ func updateLayout(forTraits traits: Styling.Traits, style: Style)
+
+ func overrideCallbacksForStyling()
+}
+
+public extension AnyUIKitComponent {
+ func updateLayout(forTraits traits: Styling.Traits, style: Style) {}
+
+ private func update(traits: Styling.Traits, style: Style) {
+ applyStyle(style)
+ updateLayout(forTraits: traits, style: style)
+ }
+
+ func update() {
+ guard superview != nil else { return }
+ if let style = currentStyle, let traits = currentTraits {
+ update(traits: traits, style: style)
+ return
+ }
+
+ let traits: Styling.Traits = .traits(traitCollection)
+ update(traits: traits)
+ }
+
+ func update(traits: Styling.Traits) {
+ guard let style = type(of: self).style(traits) else { return }
+ currentTraits = traits
+ currentStyle = style
+ update(traits: traits, style: style)
+ }
+
+ func onDidMoveToSuperview() {
+ update()
+ }
+
+ func onTraitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ defer { update() }
+
+ guard let previousTraitCollection = previousTraitCollection else {
+ return
+ }
+
+ let prevTraits: Styling.Traits = .traits(previousTraitCollection)
+ let traits: Styling.Traits = .traits(traitCollection)
+ if prevTraits != traits {
+ currentTraits = traits
+ currentStyle = type(of: self).style(traits)
+ }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Components/ContainerView.swift b/Sources/UIKitEx/Components/ContainerView.swift
new file mode 100644
index 0000000..37f1c89
--- /dev/null
+++ b/Sources/UIKitEx/Components/ContainerView.swift
@@ -0,0 +1,15 @@
+//
+// ContainerView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 11/13/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public protocol ContainerView: UIView {}
+
+#endif
diff --git a/Sources/UIKitEx/Components/ItemViewCell.swift b/Sources/UIKitEx/Components/ItemViewCell.swift
new file mode 100644
index 0000000..92700b6
--- /dev/null
+++ b/Sources/UIKitEx/Components/ItemViewCell.swift
@@ -0,0 +1,58 @@
+//
+// AnyComponent.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/23/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public protocol ConfigurableView: UIView {
+ associatedtype Environment
+ associatedtype ContentType
+ func configure(_ value: ContentType, isSelected: Bool, env: Environment, traits: Styling.Traits)
+ static func estimatedSize(traits: Styling.Traits) -> CGSize
+ static var logSizeDiff: Bool { get }
+}
+
+public extension ConfigurableView {
+ static func estimatedSize(traits: Styling.Traits) -> CGSize {
+ .zero
+ }
+
+ static var logSizeDiff: Bool {
+ true
+ }
+}
+
+open class ItemViewCell: UICollectionViewCell {
+ public var itemView = V()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ itemView.translatesAutoresizingMaskIntoConstraints = false
+ contentView.addSubview(itemView)
+ itemView.align(to: contentView).activate()
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ public func configure(_ value: V.ContentType, isSelected: Bool, env: V.Environment, traits: Styling.Traits) {
+ itemView.configure(value, isSelected: isSelected, env: env, traits: traits)
+ }
+
+ public override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
+ systemLayoutSizeFitting(targetSize)
+ }
+
+ public override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize {
+ itemView.systemLayoutSizeFitting(targetSize)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Components/RootScrollView.swift b/Sources/UIKitEx/Components/RootScrollView.swift
new file mode 100644
index 0000000..55eee1d
--- /dev/null
+++ b/Sources/UIKitEx/Components/RootScrollView.swift
@@ -0,0 +1,56 @@
+//
+// RootScrollView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 7/6/17.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public class RootScrollView: UIScrollView {
+ private var savedBottomInset: CGFloat = 0
+ private var isKeyboardShown = false
+
+ private let keyboardManager = KeyboardManager()
+
+ public override init(frame: CGRect) {
+ super.init(frame: frame)
+ keyboardManager.scrollView = self
+ }
+
+ public required init?(coder aDecoder: NSCoder) {
+ super.init(coder: aDecoder)
+ keyboardManager.scrollView = self
+ }
+
+ public func applyKeyboardBottomInset() {
+ keyboardManager.applyKeyboardBottomInset()
+ }
+
+ public func moveToTop() {
+ setContentOffset(.zero, animated: false)
+ }
+
+ public func scrollToEnd() {
+ let rect = CGRect(x: 0, y: contentSize.height - 1, width: 1, height: 1)
+ scrollRectToVisible(rect, animated: true)
+ }
+
+ public func trackKeyboard(enabled: Bool) {
+ keyboardManager.trackKeyboard(enabled: enabled)
+ }
+
+ public override func layoutSubviews() {
+ super.layoutSubviews()
+ invalidateIntrinsicContentSize()
+ }
+
+ public override var intrinsicContentSize: CGSize {
+ return contentSize
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Components/StyledButton.swift b/Sources/UIKitEx/Components/StyledButton.swift
new file mode 100644
index 0000000..e1b751a
--- /dev/null
+++ b/Sources/UIKitEx/Components/StyledButton.swift
@@ -0,0 +1,95 @@
+//
+// StyledButton.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/22/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+import UIKit
+
+public extension Styling {
+ struct Button {
+ public var view: View
+ public var contentEdgeInsets: EdgeInsets?
+ public var imageEdgeInsets: EdgeInsets?
+ public var titleFont: F
+ public var titleColor: Color
+ public var textShadow: Shadow?
+
+ public init(view: Styling.View, contentEdgeInsets: Styling.EdgeInsets? = nil, imageEdgeInsets: Styling.EdgeInsets? = nil, titleFont: F, titleColor: Styling.Color, textShadow: Styling.Shadow? = nil) {
+ self.view = view
+ self.contentEdgeInsets = contentEdgeInsets
+ self.imageEdgeInsets = imageEdgeInsets
+ self.titleFont = titleFont
+ self.titleColor = titleColor
+ self.textShadow = textShadow
+ }
+ }
+
+ static var buttonDisabledStateAlpha: CGFloat = 1
+}
+
+open class StyledButton: UIButton, AnyUIKitComponent {
+ public override var intrinsicContentSize: CGSize {
+ let contentSize = super.intrinsicContentSize
+ guard let style = currentStyle else { return contentSize }
+ let height = style.view.height ?? contentSize.height
+ return CGSize(width: contentSize.width, height: height)
+ }
+
+ // Styling
+
+ public override var isEnabled: Bool {
+ didSet {
+ alpha = isEnabled ? 1.0 : Styling.buttonDisabledStateAlpha
+ }
+ }
+
+ public typealias Style = Styling.Button
+ public var currentStyle: Style?
+ public var currentTraits: Styling.Traits?
+
+ open class func style(_ traits: Styling.Traits) -> Style? { nil }
+
+ open func applyStyle(_ style: Style) {
+ applyStyle(style.view)
+
+ titleLabel?.removeTextShadow()
+
+ if let textShadow = style.textShadow {
+ titleLabel?.addTextShadow(textShadow)
+ }
+
+ if let contentEdgeInsets = style.contentEdgeInsets {
+ self.contentEdgeInsets = .edgeInsets(for: contentEdgeInsets)
+ }
+ if let imageEdgeInsets = style.imageEdgeInsets {
+ self.imageEdgeInsets = .edgeInsets(for: imageEdgeInsets)
+ }
+
+ titleLabel?.font = F.font(for: style.titleFont)
+ setTitleColor(.color(for: style.titleColor), for: .normal)
+ }
+
+ open func updateLayout(forTraits traits: Styling.Traits, style: Style) {
+ invalidateIntrinsicContentSize()
+ }
+
+ // Callbacks for styling
+
+ public func overrideCallbacksForStyling() {}
+
+ public override func didMoveToSuperview() {
+ super.didMoveToSuperview()
+ onDidMoveToSuperview()
+ }
+
+ public override func traitCollectionDidChange(_ previous: UITraitCollection?) {
+ super.traitCollectionDidChange(previous)
+ onTraitCollectionDidChange(previous)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Components/StyledFloatingCard.swift b/Sources/UIKitEx/Components/StyledFloatingCard.swift
new file mode 100644
index 0000000..6bc2c2a
--- /dev/null
+++ b/Sources/UIKitEx/Components/StyledFloatingCard.swift
@@ -0,0 +1,98 @@
+//
+// StyledFloatingCard.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/23/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension Styling {
+ struct FloatingCard {
+ public var shadow: Shadow
+ public var cornerRadius: CGFloat
+ public var roundBottom = true
+
+ public init(shadow: Styling.Shadow, cornerRadius: CGFloat, roundBottom: Bool = true) {
+ self.shadow = shadow
+ self.cornerRadius = cornerRadius
+ self.roundBottom = roundBottom
+ }
+
+ public static var `default`: (_ traits: Styling.Traits) -> Self = { _ in
+ .init(
+ shadow: .init(offset: .init(width: 0, height: 3), radius: 3, opacity: 0.1),
+ cornerRadius: 7
+ )
+ }
+ }
+}
+
+public typealias FloatingCardStyle = Styling.FloatingCard
+
+// Knowing the type of the content wrapped in a card helps working with the content.
+open class FloatingCard: UIView, ContainerView {
+ public typealias Style = FloatingCardStyle
+ public typealias StyleFunc = (_ traits: Styling.Traits) -> Style
+ public typealias ContentView = V
+
+ public private(set) var contentView: V
+ private var styleFunc: StyleFunc
+
+ private func applyStyle(_ style: Style) {
+ contentView.layer.cornerRadius = style.cornerRadius
+ if !style.roundBottom {
+ contentView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
+ }
+ contentView.clipsToBounds = true
+
+ layer.masksToBounds = false
+ layer.cornerRadius = style.cornerRadius
+ if !style.roundBottom {
+ layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
+ }
+ addShadow(style.shadow)
+ }
+
+ private func updateStyle() {
+ applyStyle(styleFunc(.traits(traitCollection)))
+ }
+
+ public init(_ contentView: V, styleFunc: @escaping StyleFunc) {
+ self.contentView = contentView
+ self.styleFunc = styleFunc
+
+ super.init(frame: .zero)
+
+ contentView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(contentView)
+ contentView.align(to: self).activate()
+ }
+
+ public convenience override init(frame: CGRect) {
+ self.init(V(), styleFunc: Style.default)
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ public override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize {
+ contentView.systemLayoutSizeFitting(targetSize)
+ }
+
+ public override func didMoveToSuperview() {
+ super.didMoveToSuperview()
+ updateStyle()
+ }
+
+ public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+ updateStyle()
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Components/StyledLabel.swift b/Sources/UIKitEx/Components/StyledLabel.swift
new file mode 100644
index 0000000..0f85186
--- /dev/null
+++ b/Sources/UIKitEx/Components/StyledLabel.swift
@@ -0,0 +1,150 @@
+//
+// StyledLabel.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/23/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+import UIKit
+
+public extension UILabel {
+ func apply(_ styleArg: Styling.Text) {
+ var style = styleArg
+
+ removeTextShadow()
+ if numberOfLines == 1 {
+ style.lineSpacing = nil
+ }
+ attributedText = text?.styled(as: style)
+ if let shadow = style.shadow {
+ addTextShadow(shadow)
+ }
+ }
+}
+
+open class AnyStyledLabel: UILabel, AnyUIKitComponent {
+ public typealias Style = Styling.Text
+ public var currentStyle: Style?
+ public var currentTraits: Styling.Traits?
+
+ open class func style(_ traits: Styling.Traits) -> Style? { nil }
+
+ open class func singleLineHeight(_ traits: Styling.Traits) -> CGFloat? {
+ guard let style = style(traits) else { return nil }
+ let font: UIFont = F.font(for: style.font)
+ return font.lineHeight
+ }
+
+ open func updateLayout(forTraits traits: Styling.Traits, style: Style) {
+ }
+
+ open func applyStyle(_ style: Style) {
+ apply(style)
+ }
+
+ // Callbacks for styling
+
+ public func overrideCallbacksForStyling() {}
+
+ public override func didMoveToSuperview() {
+ super.didMoveToSuperview()
+ onDidMoveToSuperview()
+ }
+
+ public override func traitCollectionDidChange(_ previous: UITraitCollection?) {
+ super.traitCollectionDidChange(previous)
+ onTraitCollectionDidChange(previous)
+ }
+}
+
+open class StyledLabel: AnyStyledLabel {
+ private var savedText: String? = ""
+
+ public var styleFunc: Styling.TextFunc?
+
+ public func applyStyleFunc() {
+ let traits: Styling.Traits = .traits(traitCollection)
+ guard let styleFunc = styleFunc else { return update() }
+ apply(styleFunc(traits))
+ }
+
+ public override var text: String? {
+ get {
+ return savedText
+ }
+ set {
+ savedText = newValue
+ applyStyleFunc()
+ }
+ }
+
+ public override var textColor: UIColor! {
+ didSet {
+ applyStyleFunc()
+ }
+ }
+}
+
+open class MixedStyleLabel: AnyStyledLabel {
+ private var isStyling = false
+
+ public override func didMoveToWindow() {
+ super.didMoveToWindow()
+
+ if singleLineLayout {
+ lineBreakMode = .byTruncatingTail
+ numberOfLines = 1
+ adjustsFontSizeToFitWidth = true
+ }
+ else {
+ numberOfLines = 0
+ }
+ }
+
+ public var singleLineLayout = true
+
+ public var styledText: (_ traits: Styling.Traits) -> NSAttributedString = { _ in
+ NSAttributedString()
+ } {
+ didSet {
+ guard let traits = currentTraits else { return }
+ isStyling = true
+ attributedText = styledText(traits)
+ isStyling = false
+ }
+ }
+
+ // not used but should be here to get to updateLayout()
+ public override class func style(_ traits: Styling.Traits) -> Style {
+ Style(font: F.default(traits))
+ }
+
+ public override func applyStyle(_ style: Style) {
+ }
+
+ public override func updateLayout(forTraits traits: Styling.Traits, style: Style) {
+ // use the layout callback to update the style because it has the most complete info
+ isStyling = true
+ attributedText = styledText(traits)
+ isStyling = false
+ }
+
+ public override var text: String? {
+ didSet {
+ assert(
+ text == attributedText?.string,
+ "Use the styledText property to change the text"
+ )
+ }
+ }
+
+ public override var attributedText: NSAttributedString? {
+ didSet {
+ assert(isStyling, "Use the styledText property to change the text")
+ }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Components/StyledSelectionIndicator.swift b/Sources/UIKitEx/Components/StyledSelectionIndicator.swift
new file mode 100644
index 0000000..5d6d338
--- /dev/null
+++ b/Sources/UIKitEx/Components/StyledSelectionIndicator.swift
@@ -0,0 +1,148 @@
+//
+// StyledSelectionIndicator.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/22/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+import UIKit
+
+public extension Styling {
+ struct SelectionIndicator {
+ public var thickness: CGFloat
+ public var color: Color
+
+ public init(thickness: CGFloat, color: Styling.Color) {
+ self.thickness = thickness
+ self.color = color
+ }
+ }
+}
+
+open class StyledSelectionIndicator: UIView, AnyUIKitComponent {
+ public enum Mode {
+ case horizontalBottom, horizontalTop, vertical
+ }
+
+ public var mode: Mode = .horizontalBottom
+
+ private var layout = Layout()
+ weak var selectedView: UIView?
+
+ public override func didMoveToWindow() {
+ super.didMoveToWindow()
+ translatesAutoresizingMaskIntoConstraints = false
+ }
+
+ private func moveAbove(_ view: UIView) -> UIView? {
+ defer {
+ superview?.bringSubviewToFront(self)
+ }
+
+ if let superview = self.superview {
+ if let sameLevelView = sequence(first: view, next: { $0.superview }).first(where: { $0.superview === superview }) {
+ superview.insertSubview(self, aboveSubview: sameLevelView)
+ return superview
+ }
+ else {
+ view.superview?.insertSubview(self, aboveSubview: view)
+ return view.superview
+ }
+ }
+ else {
+ guard let viewSuperview = view.superview else {
+ return nil
+ }
+ viewSuperview.insertSubview(self, aboveSubview: view)
+ return viewSuperview
+ }
+ }
+
+ private func updateLayout(for view: UIView) {
+ layout.reset()
+
+ switch mode {
+ case .horizontalBottom:
+ layout.add(alignBottomAndSides(to: view))
+
+ case .horizontalTop:
+ layout.add(alignTopAndSides(to: view))
+
+ case .vertical:
+ layout.add(alignLeftAndSides(to: view))
+ }
+
+ layout.activate()
+ }
+
+ public func moveSelection(to view: UIView, animated: Bool) {
+ let origSuperview = superview
+ guard let commonAncestor = moveAbove(view) else { return }
+ let animate = animated && (origSuperview === commonAncestor)
+
+ guard animate else {
+ updateLayout(for: view)
+ commonAncestor.layoutIfNeeded()
+ selectedView = view
+ return
+ }
+
+ if let selectedView = selectedView {
+ updateLayout(for: selectedView)
+ }
+ commonAncestor.layoutIfNeeded()
+ selectedView = view
+
+ UIView.animate(withDuration: 0.2) {
+ self.updateLayout(for: view)
+ commonAncestor.layoutIfNeeded()
+ }
+ }
+
+ public override var intrinsicContentSize: CGSize {
+ guard let style = currentStyle else {
+ return super.intrinsicContentSize
+ }
+
+ switch mode {
+ case .horizontalBottom, .horizontalTop:
+ return CGSize(width: UIView.noIntrinsicMetric, height: style.thickness)
+ case .vertical:
+ return CGSize(width: style.thickness, height: UIView.noIntrinsicMetric)
+ }
+ }
+
+ // Styling
+
+ public typealias Style = Styling.SelectionIndicator
+ public var currentStyle: Style?
+ public var currentTraits: Styling.Traits?
+
+ open class func style(_ traits: Styling.Traits) -> Style? { nil }
+
+ open func applyStyle(_ style: Style) {
+ backgroundColor = .color(for: style.color)
+ }
+
+ open func updateLayout(forTraits traits: Styling.Traits, style: Style) {
+ invalidateIntrinsicContentSize()
+ }
+
+ // Callbacks for styling
+
+ public func overrideCallbacksForStyling() {}
+
+ public override func didMoveToSuperview() {
+ super.didMoveToSuperview()
+ onDidMoveToSuperview()
+ }
+
+ public override func traitCollectionDidChange(_ previous: UITraitCollection?) {
+ super.traitCollectionDidChange(previous)
+ onTraitCollectionDidChange(previous)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Components/StyledStringPickerControl.swift b/Sources/UIKitEx/Components/StyledStringPickerControl.swift
new file mode 100644
index 0000000..dc5a730
--- /dev/null
+++ b/Sources/UIKitEx/Components/StyledStringPickerControl.swift
@@ -0,0 +1,230 @@
+//
+// StyledStringPickerControl.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/15/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+import Combine
+import CombineEx
+
+public extension Styling {
+ struct StringPickerControl {
+ var rowCircleToTextOffset: CGFloat
+ var rowSelectionBorder: View
+ var rowSelection: View
+ var rowText: Text
+ var view: View
+
+ public init(rowCircleToTextOffset: CGFloat, rowSelectionBorder: Styling.View, rowSelection: Styling.View, rowText: Text, view: Styling.View) {
+ self.rowCircleToTextOffset = rowCircleToTextOffset
+ self.rowSelectionBorder = rowSelectionBorder
+ self.rowSelection = rowSelection
+ self.rowText = rowText
+ self.view = view
+ }
+ }
+}
+
+open class StyledStringPickerControl: UIView, AnyUIKitComponent {
+ class RowView: UIView {
+ class Label: StyledLabel {
+ override var intrinsicContentSize: CGSize {
+ var size = super.intrinsicContentSize
+ size.height *= 2.0
+ return size
+ }
+ }
+
+ weak var control: StyledStringPickerControl?
+ let label = Label()
+ let selectionBorderView = CircleView()
+ let selectionView = CircleView()
+
+ var isSelected = false {
+ didSet {
+ applySelectionColor()
+ }
+ }
+
+ private func applySelectionColor() {
+ let selectionColor = UIColor.color(for: currentStyle?.rowSelection.backgroundColor ?? .clear)
+ selectionView.backgroundColor = isSelected ? selectionColor : .clear
+ }
+
+ init(control: StyledStringPickerControl, text: String) {
+ super.init(frame: .zero)
+
+ self.control = control
+
+ selectionBorderView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(selectionBorderView)
+
+ selectionView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(selectionView)
+
+ label.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(label)
+ label.text = text
+
+ addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(updateSelection)))
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ @objc private func updateSelection() {
+ control?.updateSelection(self)
+ }
+
+ // Styling
+
+ typealias Style = Styling.StringPickerControl
+ var currentStyle: Style?
+
+ private var layout = Layout()
+
+ func applyStyle(_ style: Style) {
+ currentStyle = style
+ selectionBorderView.applyStyle(style.rowSelectionBorder)
+ selectionView.applyStyle(style.rowSelection)
+ label.applyStyle(style.rowText)
+ applySelectionColor()
+ }
+
+ func updateLayout(forTraits traits: Styling.Traits, style: Style) {
+ layout.reset()
+
+ // selectionBorderView
+ layout.add(selectionBorderView.alignVerticalCenter(to: self))
+ layout.add(selectionBorderView.alignLeftEdge(to: self, insetBy: style.rowCircleToTextOffset))
+ layout.add(selectionBorderView.constraintHeight(to: style.rowSelectionBorder.height ?? 0))
+ layout.add(selectionBorderView.constraintWidth(to: style.rowSelectionBorder.width ?? 0))
+
+ // selectionView
+ layout.add(selectionView.alignCenter(to: selectionBorderView))
+ layout.add(selectionView.constraintHeight(to: style.rowSelection.height ?? 0))
+ layout.add(selectionView.constraintWidth(to: style.rowSelection.width ?? 0))
+
+ // label
+ layout.add(label.alignRightAndSides(to: self))
+ layout.add(label.alignLeftEdge(toRightEdgeOf: selectionBorderView, offsetBy: style.rowCircleToTextOffset))
+
+ layout.activate()
+ }
+ }
+
+ public private(set) var strings: [String] = []
+ public var selectionIndex: Int? {
+ didSet {
+ _selectedString.send(selectionIndex.map { strings[$0] })
+ }
+ }
+
+ private var _selectedString: PassthroughSubject = .init()
+ public var selectedString: AnyPublisher {
+ _selectedString.eraseToAnyPublisher()
+ }
+
+ private var contentView = UIStackView()
+ private var layout = Layout()
+
+ public init() {
+ super.init(frame: .zero)
+ contentView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(contentView)
+ contentView.axis = .vertical
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ public func configure(strings: [String], selectionIndex: Int?) {
+ self.strings = strings
+ self.selectionIndex = selectionIndex
+
+ for view in contentView.arrangedSubviews {
+ view.removeFromSuperview()
+ }
+
+ for string in strings {
+ let rowView = RowView(control: self, text: string)
+ rowView.label.hasBottomSeparator = true
+ contentView.addArrangedSubview(rowView)
+ }
+ (contentView.arrangedSubviews.last as? RowView)?.label.hasBottomSeparator = false
+
+ if let selectionIndex = selectionIndex {
+ updateRowView(at: selectionIndex, isSelected: true)
+ }
+
+ update()
+ }
+
+ private func updateSelection(_ rowView: RowView) {
+ guard let index = contentView.arrangedSubviews.firstIndex(of: rowView) else { return }
+ if let oldSelectionIndex = selectionIndex {
+ updateRowView(at: oldSelectionIndex, isSelected: false)
+ }
+
+ if index == selectionIndex {
+ selectionIndex = nil
+ }
+ else {
+ updateRowView(at: index, isSelected: true)
+ selectionIndex = index
+ }
+ }
+
+ private func updateRowView(at index: Int, isSelected: Bool) {
+ (contentView.arrangedSubviews[index] as? RowView)?.isSelected = isSelected
+ }
+
+ // Styling
+
+ public typealias Style = Styling.StringPickerControl
+ public var currentStyle: Style?
+ public var currentTraits: Styling.Traits?
+
+ open class func style(_ traits: Styling.Traits) -> Style? { nil }
+
+ open func applyStyle(_ style: Style) {
+ applyStyle(style.view)
+
+ for case let view as RowView in contentView.arrangedSubviews {
+ view.applyStyle(style)
+ }
+ }
+
+ open func updateLayout(forTraits traits: Styling.Traits, style: Style) {
+ layout.reset()
+ layout.add(contentView.align(to: self))
+ layout.activate()
+
+ for case let view as RowView in contentView.arrangedSubviews {
+ view.updateLayout(forTraits: traits, style: style)
+ }
+ }
+
+ // Callbacks for styling
+
+ public func overrideCallbacksForStyling() {}
+
+ public override func didMoveToSuperview() {
+ super.didMoveToSuperview()
+ onDidMoveToSuperview()
+ }
+
+ public override func traitCollectionDidChange(_ previous: UITraitCollection?) {
+ super.traitCollectionDidChange(previous)
+ onTraitCollectionDidChange(previous)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Components/StyledSwitch.swift b/Sources/UIKitEx/Components/StyledSwitch.swift
new file mode 100644
index 0000000..d882d18
--- /dev/null
+++ b/Sources/UIKitEx/Components/StyledSwitch.swift
@@ -0,0 +1,50 @@
+//
+// Switch.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 7/11/17.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+import UIKit
+
+public extension Styling {
+ struct Switch {
+ public var color: Color
+
+ public init(color: Styling.Color) {
+ self.color = color
+ }
+ }
+}
+
+open class StyledSwitch: UISwitch, AnyUIKitComponent {
+ // Styling
+
+ public typealias Style = Styling.Switch
+ public var currentStyle: Style?
+ public var currentTraits: Styling.Traits?
+
+ open class func style(_ traits: Styling.Traits) -> Style? { nil }
+
+ open func applyStyle(_ style: Style) {
+ onTintColor = .color(for: style.color)
+ }
+
+ // Callbacks for styling
+
+ public func overrideCallbacksForStyling() {}
+
+ public override func didMoveToSuperview() {
+ super.didMoveToSuperview()
+ onDidMoveToSuperview()
+ }
+
+ public override func traitCollectionDidChange(_ previous: UITraitCollection?) {
+ super.traitCollectionDidChange(previous)
+ onTraitCollectionDidChange(previous)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Components/StyledTextField.swift b/Sources/UIKitEx/Components/StyledTextField.swift
new file mode 100644
index 0000000..1c9f75b
--- /dev/null
+++ b/Sources/UIKitEx/Components/StyledTextField.swift
@@ -0,0 +1,159 @@
+//
+// StyledTextField.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/22/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+import UIKit
+
+public extension Styling {
+ struct TextField {
+ public var font: F
+ public var textColor: Color?
+ public var placeholderColor: Color?
+ public var backgroundColor: Color = .white
+ public var readonlyBackgroundColor: Color?
+ public var textInsets: EdgeInsets
+ public var caretInsets: EdgeInsets?
+ public var boxShadow: Shadow?
+
+ public init(font: F, textColor: Styling.Color? = nil, placeholderColor: Styling.Color? = nil, backgroundColor: Styling.Color = .white, readonlyBackgroundColor: Styling.Color? = nil, textInsets: Styling.EdgeInsets, caretInsets: Styling.EdgeInsets? = nil, boxShadow: Styling.Shadow? = nil) {
+ self.font = font
+ self.textColor = textColor
+ self.placeholderColor = placeholderColor
+ self.backgroundColor = backgroundColor
+ self.readonlyBackgroundColor = readonlyBackgroundColor
+ self.textInsets = textInsets
+ self.caretInsets = caretInsets
+ self.boxShadow = boxShadow
+ }
+ }
+}
+
+open class StyledTextField: UITextField, InputView, AnyUIKitComponent {
+ public var isReadOnly = false {
+ didSet {
+ isUserInteractionEnabled = !isReadOnly
+ update()
+ }
+ }
+
+ // Styling
+
+ public typealias Style = Styling.TextField
+ public var currentStyle: Style?
+ public var currentTraits: Styling.Traits?
+
+ open class func style(_ traits: Styling.Traits) -> Style? { nil }
+
+ open func applyStyle(_ style: Style) {
+ removeShadow()
+
+ let styleBackgroundColor = isReadOnly ? style.readonlyBackgroundColor : style.backgroundColor
+ borderStyle = .none
+ backgroundColor = .maybeColor(for: styleBackgroundColor)
+ font = F.font(for: style.font)
+ tintColor = .maybeColor(for: style.textColor)
+ isOpaque = (backgroundColor != nil)
+ addShadow(style.boxShadow)
+ }
+
+ open func updateLayout(forTraits traits: Styling.Traits, style: Style) {
+ invalidateIntrinsicContentSize()
+ }
+
+ // Callbacks for styling
+
+ public func overrideCallbacksForStyling() {}
+
+ public override func didMoveToSuperview() {
+ super.didMoveToSuperview()
+ onDidMoveToSuperview()
+ }
+
+ public override func traitCollectionDidChange(_ previous: UITraitCollection?) {
+ super.traitCollectionDidChange(previous)
+ onTraitCollectionDidChange(previous)
+ }
+
+ // InputView
+
+ public weak var nextInputView: InputView?
+ public weak var previousInputView: InputView?
+
+ // UIKit overrides
+
+ public override var keyboardType: UIKeyboardType {
+ didSet {
+ if keyboardType != oldValue {
+ reloadInputViews()
+ }
+ }
+ }
+
+ public override func becomeFirstResponder() -> Bool {
+ guard !isReadOnly else { return false }
+ guard super.becomeFirstResponder() else { return false }
+ postDidBecomeFirstResponderNotification()
+ onBecomeFirstResponder()
+ return true
+ }
+
+ // Cannot override becomeFirstResponder() -- that crashes Xcode previews
+ open func onBecomeFirstResponder() {
+ }
+
+ public override func caretRect(for position: UITextPosition) -> CGRect {
+ var rect = super.caretRect(for: position)
+ guard let style = currentStyle else { return rect }
+
+ if let insets = style.caretInsets {
+ rect.origin.y += insets.top
+ rect.size.height -= (insets.top + insets.bottom)
+ }
+ rect.size.width = 1
+ return rect
+ }
+
+ public override func drawPlaceholder(in rect: CGRect) {
+ guard let placeholder = placeholder else { return }
+ guard let style = currentStyle else { return }
+
+ var attributes: [NSAttributedString.Key: Any] = [:]
+ attributes[.font] = F.font(for: style.font)
+ attributes[.foregroundColor] = UIColor.maybeColor(for: style.placeholderColor)
+
+ placeholder.draw(at: rect.origin, withAttributes: attributes)
+ }
+
+ public override func textRect(forBounds bounds: CGRect) -> CGRect {
+ var rect = super.textRect(forBounds: bounds)
+ guard let style = currentStyle else { return rect }
+
+ let insets = style.textInsets
+ rect.origin.x += insets.left
+ rect.origin.y += insets.top
+ rect.size.width -= (insets.left + insets.right)
+ rect.size.height -= (insets.top + insets.bottom)
+ return rect
+ }
+
+ public override func editingRect(forBounds bounds: CGRect) -> CGRect {
+ return textRect(forBounds: bounds)
+ }
+
+ public override var intrinsicContentSize: CGSize {
+ var size = super.intrinsicContentSize
+ guard let style = currentStyle else { return size }
+
+ let insets = style.textInsets
+ size.width += (insets.left + insets.right)
+ size.height += (insets.top + insets.bottom)
+ return size
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Components/StyledTextView.swift b/Sources/UIKitEx/Components/StyledTextView.swift
new file mode 100644
index 0000000..5dd821a
--- /dev/null
+++ b/Sources/UIKitEx/Components/StyledTextView.swift
@@ -0,0 +1,128 @@
+//
+// StyledTextView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/23/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+import UIKit
+
+open class StyledTextView: UITextView, InputView, AnyUIKitComponent {
+ fileprivate var placeholderLabel: UILabel!
+
+ public var placeholder: String? {
+ didSet {
+ placeholderLabel.text = placeholder
+ }
+ }
+
+ @objc fileprivate func textDidChange() {
+ placeholderLabel.isShown = text.isEmpty
+ onTextDidChange()
+ }
+
+ public func onTextDidChange() {}
+
+ public override var text: String! {
+ didSet {
+ textDidChange()
+ }
+ }
+
+ public override init(frame: CGRect, textContainer: NSTextContainer?) {
+ super.init(frame: frame, textContainer: textContainer)
+ configure()
+ }
+
+ public required init?(coder aDecoder: NSCoder) {
+ super.init(coder: aDecoder)
+ configure()
+ }
+
+ private var isConfigured = false
+
+ public func configure() {
+ guard !isConfigured else { return }
+ defer { isConfigured = true }
+
+ placeholderLabel = UILabel()
+ placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(placeholderLabel)
+ placeholderLabel.alignTopAndSides(
+ to: self,
+ insetBy: UIEdgeInsets(
+ top: textContainerInset.top,
+ left: textContainerInset.left + textContainer.lineFragmentPadding,
+ bottom: 0,
+ right: textContainerInset.right + textContainer.lineFragmentPadding
+ )
+ )
+ .activate()
+
+ NotificationCenter.default.addObserver(self, selector: #selector(textDidChange), name: UITextView.textDidChangeNotification, object: self)
+ }
+
+ // MARK: InputView
+
+ public weak var nextInputView: InputView?
+ public weak var previousInputView: InputView?
+
+ // Styling
+
+ public typealias Style = Styling.TextField
+ public var currentStyle: Style?
+ public var currentTraits: Styling.Traits?
+
+ open class func style(_ traits: Styling.Traits) -> Style? { nil }
+
+ open func applyStyle(_ style: Style) {
+ backgroundColor = .color(for: style.backgroundColor)
+ font = F.font(for: style.font)
+ textColor = .maybeColor(for: style.textColor)
+
+ placeholderLabel.backgroundColor = .clear
+ placeholderLabel.font = font
+ placeholderLabel.textColor = .maybeColor(for: style.placeholderColor)
+
+ // Adding a real shadow would require a view under the text view (the text view
+ // clips its bounds because otherwise the text that doesn't fit the frame is still displayed
+ // beyond the text view bounds). Having to deal with this additional view is error prone.
+ // The code below provides some styling while keeping the code simple.
+ if style.boxShadow != nil {
+ layer.borderWidth = currentTraits?.separatorThickness ?? 1
+ layer.borderColor = UIColor.color(for: .separator).cgColor
+ layer.cornerRadius = 4
+ }
+ }
+
+ // Callbacks for styling
+
+ public func overrideCallbacksForStyling() {}
+
+ public override func didMoveToSuperview() {
+ super.didMoveToSuperview()
+ onDidMoveToSuperview()
+ }
+
+ public override func traitCollectionDidChange(_ previous: UITraitCollection?) {
+ super.traitCollectionDidChange(previous)
+ onTraitCollectionDidChange(previous)
+ }
+
+ // UIKit overrides
+
+ public override func becomeFirstResponder() -> Bool {
+ guard super.becomeFirstResponder() else { return false }
+ onBecomeFirstResponder()
+
+ postDidBecomeFirstResponderNotification()
+ return true
+ }
+
+ open func onBecomeFirstResponder() {
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Environment.swift b/Sources/UIKitEx/Environment.swift
new file mode 100644
index 0000000..5c5b0d9
--- /dev/null
+++ b/Sources/UIKitEx/Environment.swift
@@ -0,0 +1,22 @@
+//
+// Environment.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 03/30/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public enum UIKitEx {
+ public struct Environment {
+ public var urlDataPublisher = URLDataPublisher.default
+ public var vcViewClass: UIView.Type = UIView.self
+ }
+
+ public static var env = Environment()
+}
+
+#endif
diff --git a/Sources/UIKitEx/Error.swift b/Sources/UIKitEx/Error.swift
new file mode 100644
index 0000000..67a31c7
--- /dev/null
+++ b/Sources/UIKitEx/Error.swift
@@ -0,0 +1,19 @@
+//
+// Error.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 03/30/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UIKitEx {
+ enum Error: Swift.Error {
+ case general(String)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/CGSize.swift b/Sources/UIKitEx/Extensions/CGSize.swift
new file mode 100644
index 0000000..f02c7a3
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/CGSize.swift
@@ -0,0 +1,35 @@
+//
+// CGSize.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 2/1/18.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension CGSize {
+ var isPortrait: Bool {
+ return height >= width
+ }
+
+ var isLandscape: Bool {
+ return !isPortrait
+ }
+
+ var maxSideLength: CGFloat {
+ return max(width, height)
+ }
+
+ var aspectRatio: CGFloat {
+ (height == 0) ? 0 : width / height
+ }
+
+ func applying(dx: CGFloat, dy: CGFloat) -> CGSize {
+ .init(width: width + dx, height: height + dy)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/NSMutableAttributedString.swift b/Sources/UIKitEx/Extensions/NSMutableAttributedString.swift
new file mode 100644
index 0000000..199a963
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/NSMutableAttributedString.swift
@@ -0,0 +1,21 @@
+//
+// NSMutableAttributedString.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 7/14/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import Foundation
+
+public extension NSMutableAttributedString {
+ func emphasize(_ text: String, as style: Styling.Text) {
+ let textRange = (string as NSString).range(of: text)
+ let formattedText = text.styled(as: style)
+ replaceCharacters(in: textRange, with: formattedText)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/Previews.swift b/Sources/UIKitEx/Extensions/Previews.swift
new file mode 100644
index 0000000..0f41474
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/Previews.swift
@@ -0,0 +1,78 @@
+//
+// Previews.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 12/21/19.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+import SwiftUI
+
+public enum AppPreviews {
+ public static var devices = [
+ "iPhone 11",
+ "iPad (6th generation)"
+ ]
+
+ @ViewBuilder
+ public static func all(_ builder: @escaping () -> ViewController) -> some View {
+ ForEach(devices, id: \.self) { device in
+ UIViewControllerPreview(builder)
+ .previewDevice(PreviewDevice(rawValue: device))
+ .previewDisplayName(device)
+ }
+ }
+
+ public static func all(_ builder: @escaping () -> ContentView) -> some View {
+ ForEach(devices, id: \.self) { device in
+ UIViewPreview(builder)
+ .padding(20)
+ .background(Color.init(white: 0.9))
+ .previewDevice(PreviewDevice(rawValue: device))
+ .previewDisplayName(device)
+ }
+ }
+}
+
+public struct UIViewPreview: UIViewRepresentable {
+ let view: View
+ public init(_ builder: () -> View) {
+ view = builder()
+ }
+
+ // MARK: - UIViewRepresentable
+
+ public func makeUIView(context: Context) -> UIView {
+ return view
+ }
+
+ public func updateUIView(_ view: UIView, context: Context) {
+ view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
+ view.setContentHuggingPriority(.defaultHigh, for: .vertical)
+ }
+}
+
+public struct UIViewControllerPreview: UIViewControllerRepresentable {
+ let viewController: ViewController
+
+ public init(_ builder: () -> ViewController) {
+ viewController = builder()
+ }
+
+ // MARK: - UIViewControllerRepresentable
+ public func makeUIViewController(context: Context) -> ViewController {
+ viewController
+ }
+
+ public func updateUIViewController(
+ _ vc: ViewController,
+ context: UIViewControllerRepresentableContext>
+ ) {
+ return
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/TextControl.swift b/Sources/UIKitEx/Extensions/TextControl.swift
new file mode 100644
index 0000000..dc557c9
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/TextControl.swift
@@ -0,0 +1,51 @@
+//
+// TextControl.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/18/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+import Combine
+import CombineEx
+
+public protocol TextControl: UIView, UITextInput {
+ static var textDidChangeNotification: Notification.Name { get }
+ static var textDidBeginEditingNotification: Notification.Name { get }
+ static var textDidEndEditingNotification: Notification.Name { get }
+ var textValue: String? { get set }
+}
+
+public extension TextControl {
+ var textDidChangePublisher: AnyPublisher {
+ NotificationCenter.default
+ .publisher(for: Self.textDidChangeNotification, object: self)
+ .compactMap { [weak self] _ in
+ self?.textValue ?? ""
+ }
+ .eraseToAnyPublisher()
+ }
+
+ var textDidBeginEditingPublisher: AnyPublisher {
+ NotificationCenter.default
+ .publisher(for: Self.textDidBeginEditingNotification, object: self)
+ .map { _ in () }
+ .eraseToAnyPublisher()
+ }
+
+ var textDidEndEditingPublisher: AnyPublisher {
+ NotificationCenter.default
+ .publisher(for: Self.textDidEndEditingNotification, object: self)
+ .map { _ in () }
+ .eraseToAnyPublisher()
+ }
+
+ func update(_ value: String) {
+ textValue = value
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UIBarButtonItem.swift b/Sources/UIKitEx/Extensions/UIBarButtonItem.swift
new file mode 100644
index 0000000..64ecfea
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UIBarButtonItem.swift
@@ -0,0 +1,21 @@
+//
+// UIBarButtonItem.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 9/26/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UIBarButtonItem {
+ func sendAction() {
+ guard let action = action else { return }
+ guard let target = target else { return }
+ perform(action, with: target)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UICollectionReusableView.swift b/Sources/UIKitEx/Extensions/UICollectionReusableView.swift
new file mode 100644
index 0000000..b4bf756
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UICollectionReusableView.swift
@@ -0,0 +1,23 @@
+//
+// UICollectionReusableView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 12/22/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UICollectionReusableView {
+ func measure(availableSize: CGSize) -> CGSize {
+ frame = CGRect(origin: .zero, size: availableSize)
+ setNeedsLayout()
+ layoutIfNeeded()
+ let size = systemLayoutSizeFitting(availableSize)
+ return size
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UICollectionView.swift b/Sources/UIKitEx/Extensions/UICollectionView.swift
new file mode 100644
index 0000000..e29d874
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UICollectionView.swift
@@ -0,0 +1,94 @@
+//
+// UICollectionView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 6/19/17.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+/// Values for diffable data sources
+public struct Box: Hashable {
+ public let index: Int
+ public let value: T
+ public let isSelected: Bool
+
+ public init(index: Int, value: T, isSelected: Bool) {
+ self.index = index
+ self.value = value
+ self.isSelected = isSelected
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(index)
+ hasher.combine(isSelected)
+ }
+
+ public static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.index == rhs.index &&
+ lhs.isSelected == rhs.isSelected
+ }
+}
+
+public struct Box2: Hashable {
+ public let index1: Int
+ public let index2: Int
+ public let value: T
+
+ public init(index1: Int, index2: Int, value: T) {
+ self.index1 = index1
+ self.index2 = index2
+ self.value = value
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(index1)
+ hasher.combine(index2)
+ }
+
+ public static func == (lhs: Self, rhs: Self) -> Bool {
+ (lhs.index1 == rhs.index1) &&
+ (lhs.index2 == rhs.index2)
+ }
+}
+
+public extension UICollectionReusableView {
+ class var reuseID: String {
+ return String(describing: self)
+ }
+}
+
+public extension UICollectionView {
+ func registerClass(for cellClass: UICollectionViewCell.Type) {
+ register(cellClass.self, forCellWithReuseIdentifier: cellClass.reuseID)
+ }
+
+ func registerClass(for reusableViewClass: UICollectionReusableView.Type, kind: String) {
+ register(reusableViewClass.self, forSupplementaryViewOfKind: kind, withReuseIdentifier: reusableViewClass.reuseID)
+ }
+
+ func dequeueCell(for indexPath: IndexPath) -> T {
+ guard let cell = dequeueReusableCell(withReuseIdentifier: T.reuseID, for: indexPath) as? T else {
+ fatalError("Cell must of class \(T.reuseID)")
+ }
+
+ return cell
+ }
+
+ func dequeueCell(for indexPath: IndexPath, cellClass: UICollectionViewCell.Type) -> UICollectionViewCell {
+ return dequeueReusableCell(withReuseIdentifier: cellClass.reuseID, for: indexPath)
+ }
+
+ func dequeueSupplementaryView(ofKind kind: String, for indexPath: IndexPath) -> T {
+ guard let view = dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: T.reuseID, for: indexPath) as? T else {
+ fatalError("Supplementary View must be of type \(T.reuseID)")
+ }
+
+ return view
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UIColor.swift b/Sources/UIKitEx/Extensions/UIColor.swift
new file mode 100644
index 0000000..52a19fc
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UIColor.swift
@@ -0,0 +1,24 @@
+//
+// UIColor.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 6/21/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UIColor {
+ static func maybeColor(for style: Styling.Color?) -> UIColor? {
+ guard let style = style else { return nil }
+ return .color(for: style)
+ }
+
+ static func color(for style: Styling.Color) -> UIColor {
+ return UIColor(red: style.red, green: style.green, blue: style.blue, alpha: style.opacity)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UIControl.swift b/Sources/UIKitEx/Extensions/UIControl.swift
new file mode 100644
index 0000000..48b2e9f
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UIControl.swift
@@ -0,0 +1,70 @@
+//
+// UIControl.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 7/12/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+// based on https://forums.swift.org/t/a-uicontrol-event-publisher-example/26215
+
+#if canImport(UIKit)
+
+import UIKit
+import Combine
+import CombineEx
+
+public extension UIControl {
+ private class EventObserver {
+ let control: UIControl
+ let event: UIControl.Event
+ let subject: PassthroughSubject
+
+ init(control: UIControl, event: UIControl.Event) {
+ self.control = control
+ self.event = event
+ self.subject = .init()
+ }
+
+ func start() {
+ control.addTarget(self, action: #selector(handleEvent(from:)), for: event)
+ }
+
+ func stop() {
+ control.removeTarget(self, action: nil, for: event)
+ }
+
+ @objc func handleEvent(from sender: UIControl) {
+ subject.send(sender)
+ }
+ }
+
+ struct ControlEventPublisher: Publisher {
+ public typealias Output = UIControl
+ public typealias Failure = Never
+
+ public let control: UIControl
+ public let event: UIControl.Event
+
+ init(control: UIControl, event: UIControl.Event) {
+ self.control = control
+ self.event = event
+ }
+
+ public func receive(subscriber: S) where S.Failure == Failure, S.Input == Output {
+ let observer = EventObserver(control: control, event: event)
+ observer
+ .subject
+ .handleEvents(
+ receiveSubscription: { _ in observer.start() },
+ receiveCancel: observer.stop
+ )
+ .receive(subscriber: subscriber)
+ }
+ }
+
+ func eventPublisher(for event: UIControl.Event) -> ControlEventPublisher {
+ return ControlEventPublisher(control: self, event: event)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UIEdgeInsets.swift b/Sources/UIKitEx/Extensions/UIEdgeInsets.swift
new file mode 100644
index 0000000..f08674b
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UIEdgeInsets.swift
@@ -0,0 +1,27 @@
+//
+// UIEdgeInsets.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 6/30/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UIEdgeInsets {
+ init(offset: CGFloat) {
+ self = Self.init(top: offset, left: offset, bottom: offset, right: offset)
+ }
+
+ static func edgeInsets(for insets: Styling.EdgeInsets) -> Self {
+ return .init(top: insets.top, left: insets.left, bottom: insets.bottom, right: insets.right)
+ }
+
+ static func horizontal(left: CGFloat, right: CGFloat) -> Self {
+ return .init(top: 0, left: left, bottom: 0, right: right)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UIImage.swift b/Sources/UIKitEx/Extensions/UIImage.swift
new file mode 100644
index 0000000..13a2a33
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UIImage.swift
@@ -0,0 +1,80 @@
+//
+// UIImage.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/4/19.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import FoundationEx
+import UIKit
+import Combine
+import CombineEx
+
+public extension UIImage {
+ func scaled(by scale: Int, screenScale: CGFloat = 1) -> UIImage {
+ let rect = CGRect(x: 0, y: 0, width: size.width * CGFloat(scale), height: size.height * CGFloat(scale))
+ UIGraphicsBeginImageContextWithOptions(rect.size, false, screenScale)
+ draw(in: rect)
+ guard let newImage = UIGraphicsGetImageFromCurrentImageContext() else {
+ fatalError("Expected a valid image when scaling")
+ }
+ UIGraphicsEndImageContext()
+ return newImage
+ }
+}
+
+public extension UIImage {
+ private static var cache = Cache()
+ private static var tasks: [URL: AnySingleValuePublisher] = [:]
+
+ private static func get(url: URL, completion: @escaping (UIImage?) -> Void) {
+ assert(Thread.isMainThread)
+
+ if let image = cache[url] {
+ completion(image)
+ return
+ }
+
+ var task: AnySingleValuePublisher
+ if let dictTask = tasks[url] {
+ task = dictTask
+ }
+ else {
+ let request = URLRequest(url: url)
+ task = UIKitEx.env.urlDataPublisher(for: request)
+ .map { UIImage(data: $0.data) }
+ .replaceError(with: nil)
+ .receive(on: DispatchQueue.main)
+ .share()
+ .eraseType()
+ tasks[url] = task
+ }
+
+ task.sideEffect { value in
+ cache[url] = value
+ tasks.removeValue(forKey: url)
+ completion(value)
+ }
+ .map { _ in }
+ .runAsSideEffect()
+ }
+
+ static func get(url: URL?) -> LazyFuture {
+ guard let url = url else {
+ return LazyFuture { $0(.success(nil)) }
+ }
+
+ return LazyFuture { promise in
+ get(url: url, completion: { promise(.success($0)) })
+ }
+ }
+
+ static func clearCache() {
+ cache.removeAll()
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UIImageView.swift b/Sources/UIKitEx/Extensions/UIImageView.swift
new file mode 100644
index 0000000..4d26f2c
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UIImageView.swift
@@ -0,0 +1,26 @@
+//
+// UIImageView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 9/8/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+import Combine
+import CombineEx
+
+public extension UIImageView {
+ func getImage(url: URL?) -> AnyCancellable {
+ image = nil
+ guard let url = url else { return AnyCancellable({}) }
+ return UIImage.get(url: url).sink { [weak self] in
+ self?.image = $0
+// print("url: \(url), size: \($0?.size ?? .zero)")
+ }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UILabel.swift b/Sources/UIKitEx/Extensions/UILabel.swift
new file mode 100644
index 0000000..8bbf2f3
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UILabel.swift
@@ -0,0 +1,23 @@
+//
+// UILabel.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 6/21/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UILabel {
+ func addTextShadow(_ shadow: Styling.Shadow) {
+ addShadow(shadow)
+ }
+
+ func removeTextShadow() {
+ addShadow(nil)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UILayoutPriority.swift b/Sources/UIKitEx/Extensions/UILayoutPriority.swift
new file mode 100644
index 0000000..45aff10
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UILayoutPriority.swift
@@ -0,0 +1,17 @@
+//
+// UILayoutPriority.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 11/1/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UILayoutPriority {
+ static let almostRequired: Self = .init(rawValue: Self.required.rawValue - 1)
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UIScrollView.swift b/Sources/UIKitEx/Extensions/UIScrollView.swift
new file mode 100644
index 0000000..afeaba2
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UIScrollView.swift
@@ -0,0 +1,23 @@
+//
+// UIScrollView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 11/8/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UIScrollView {
+ var isAtTop: Bool {
+ contentOffset.y <= -contentInset.top
+ }
+
+ var isAtBottom: Bool {
+ contentOffset.y + bounds.height >= contentSize.height + contentInset.bottom
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UITabBarController.swift b/Sources/UIKitEx/Extensions/UITabBarController.swift
new file mode 100644
index 0000000..48dc34e
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UITabBarController.swift
@@ -0,0 +1,35 @@
+//
+// UITabBarController.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 2/3/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UITabBarController {
+ func index(of vc: UIViewController) -> Int? {
+ viewControllers?.firstIndex(of: vc)
+ }
+
+ func selectTab(named name: String) throws {
+ func match(item: UITabBarItem?) -> Bool {
+ guard let title = item?.title else { return false }
+ return title.caseInsensitiveCompare(name) == .orderedSame
+ }
+ guard let tabIndex = tabBar.items?.firstIndex(where: match) else {
+ throw UIKitEx.Error.general("no tab named \(name)")
+ }
+ selectedIndex = tabIndex
+ }
+
+ func select(_ vc: UIViewController) {
+ guard let index = index(of: vc) else { return assertionFailure() }
+ selectedIndex = index
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UITableView.swift b/Sources/UIKitEx/Extensions/UITableView.swift
new file mode 100644
index 0000000..bed0feb
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UITableView.swift
@@ -0,0 +1,83 @@
+//
+// UITableView.swift
+// Rocket Insights
+//
+// Created by Chris Whinfrey on 6/14/17.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UITableViewCell {
+ class var reuseID: String {
+ return String(describing: self)
+ }
+
+ var indexPath: IndexPath? {
+ guard let tableView: UITableView = enclosingSuperview() else { return nil }
+ return tableView.indexPath(for: self)
+ }
+}
+
+public extension UITableViewHeaderFooterView {
+ class var reuseID: String {
+ return String(describing: self)
+ }
+}
+
+public extension UITableView {
+ func registerNib(for cellClass: UITableViewCell.Type) {
+ register(UINib(nibName: cellClass.reuseID, bundle: nil), forCellReuseIdentifier: cellClass.reuseID)
+ }
+
+ func registerNib(forView viewClass: UITableViewHeaderFooterView.Type) {
+ register(UINib(nibName: viewClass.reuseID, bundle: nil), forHeaderFooterViewReuseIdentifier: viewClass.reuseID)
+ }
+
+ func dequeueCell(for indexPath: IndexPath) -> T {
+ guard let cell = dequeueReusableCell(withIdentifier: T.reuseID, for: indexPath) as? T else {
+ fatalError("reuse ID \(T.reuseID) must be registered for type \(T.self)")
+ }
+
+ return cell
+ }
+
+ func dequeueView(forSection section: Int) -> T {
+ guard let view = dequeueReusableHeaderFooterView(withIdentifier: T.reuseID) as? T else {
+ fatalError("reuse ID \(T.reuseID) must be registered for type \(T.self)")
+ }
+
+ return view
+ }
+
+ func deselectSelectedRow() {
+ if let selectedIndexPath = indexPathForSelectedRow {
+ deselectRow(at: selectedIndexPath, animated: true)
+ }
+ }
+
+ func isValidIndexPath(_ indexPath: IndexPath) -> Bool {
+ guard let dataSource = dataSource else {
+ assertionFailure()
+ return false
+ }
+
+ let sectionCount = dataSource.numberOfSections?(in: self) ?? 1
+ guard indexPath.section < sectionCount else {
+ assertionFailure("Expected \(sectionCount) section(s), so section \(indexPath.section) is out of range")
+ return false
+ }
+
+ let count = dataSource.tableView(self, numberOfRowsInSection: indexPath.section)
+ guard indexPath.row < count else {
+ assertionFailure("Expected \(count) row(s) in section, so row \(indexPath.row) is out out of range")
+ return false
+ }
+
+ return true
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UITextField.swift b/Sources/UIKitEx/Extensions/UITextField.swift
new file mode 100644
index 0000000..0f8c543
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UITextField.swift
@@ -0,0 +1,20 @@
+//
+// UITextField.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 7/12/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+extension UITextField: TextControl {
+ public var textValue: String? {
+ get { text }
+ set { text = newValue }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UITextView.swift b/Sources/UIKitEx/Extensions/UITextView.swift
new file mode 100644
index 0000000..3391637
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UITextView.swift
@@ -0,0 +1,20 @@
+//
+// UITextView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 7/12/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+extension UITextView: TextControl {
+ public var textValue: String? {
+ get { text }
+ set { text = newValue }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UITraitCollection.swift b/Sources/UIKitEx/Extensions/UITraitCollection.swift
new file mode 100644
index 0000000..3e9a77e
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UITraitCollection.swift
@@ -0,0 +1,38 @@
+//
+// UITraitCollection.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 1/8/18.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UITraitCollection {
+ var isCompactSize: Bool {
+ return (horizontalSizeClass != .regular) || (verticalSizeClass != .regular)
+ }
+}
+
+public extension Styling.Traits {
+ static func traits(_ traitCollection: UITraitCollection) -> Styling.Traits {
+ let componentSize: Styling.ComponentSize
+ switch (traitCollection.horizontalSizeClass, traitCollection.verticalSizeClass) {
+ case (.regular, .regular): componentSize = .regular
+ default: componentSize = .compact
+ }
+
+ return Styling.Traits(
+ componentSize: componentSize,
+ separatorThickness: 1.0 / traitCollection.displayScale
+ )
+ }
+
+ static var `default`: Styling.Traits {
+ .traits(UITraitCollection.current)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UIView+AppLayout.swift b/Sources/UIKitEx/Extensions/UIView+AppLayout.swift
new file mode 100644
index 0000000..1c1c9f3
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UIView+AppLayout.swift
@@ -0,0 +1,48 @@
+//
+// UIView+AppLayout.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 7/14/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UIView {
+ private static func maxAvailableWidth(traits: Styling.Traits, availableSize: CGSize, insetsTotal: CGFloat) -> CGFloat {
+ min(max(0, availableSize.width - insetsTotal), availableSize.isLandscape ? 580 : 460)
+ }
+
+ static func mainContentFill(view: UIView, traits: Styling.Traits, availableSize: CGSize, insetBy insets: UIEdgeInsets = .zero) -> Layout {
+ guard let superview = view.superview else {
+ assertionFailure()
+ return []
+ }
+ return view.alignVerticalEdges(to: superview, insetBy: insets)
+ }
+
+ static func mainContentCenter(view: UIView, traits: Styling.Traits, availableSize: CGSize, minInset: CGFloat) -> Layout {
+ let maxWidth = maxAvailableWidth(traits: traits, availableSize: availableSize, insetsTotal: 2.0 * minInset)
+ var layout = Layout()
+ layout.add(view.constraintWidth(to: maxWidth, priority: .required))
+ layout.add(view.centerHorzontally(minInset: minInset))
+ return layout
+ }
+
+ static func mainContentRight(view: UIView, traits: Styling.Traits, availableSize: CGSize, minInset: CGFloat) -> Layout {
+ guard let superview = view.superview else {
+ assertionFailure()
+ return Layout()
+ }
+
+ let maxWidth = maxAvailableWidth(traits: traits, availableSize: availableSize, insetsTotal: 0)
+ var layout = Layout()
+ layout.add(view.constraintWidth(to: maxWidth, priority: .required))
+ layout.add(view.alignRightEdge(to: superview))
+ return layout
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UIView+Layout.swift b/Sources/UIKitEx/Extensions/UIView+Layout.swift
new file mode 100644
index 0000000..119a158
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UIView+Layout.swift
@@ -0,0 +1,370 @@
+//
+// UIView+Layout.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 6/30/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public typealias Layout = [NSLayoutConstraint]
+
+public extension Layout {
+ func activate() {
+ NSLayoutConstraint.activate(self)
+ }
+
+ func deactivate() {
+ NSLayoutConstraint.deactivate(self)
+ }
+
+ mutating func reset() {
+ deactivate()
+ removeAll()
+ }
+
+ @discardableResult
+ mutating func add(_ value: NSLayoutConstraint?) -> NSLayoutConstraint? {
+ guard let value = value else { return nil }
+ append(value)
+ return value
+ }
+
+ mutating func add(_ values: [NSLayoutConstraint]) {
+ append(contentsOf: values)
+ }
+}
+
+public extension NSLayoutConstraint {
+ @discardableResult func activate() -> Self {
+ self.isActive = true
+ return self
+ }
+
+ @discardableResult func deactivate() -> Self {
+ self.isActive = false
+ return self
+ }
+}
+
+public extension UIView {
+ static func constraint>
+ (_ anchor1: T, to anchor2: T, constant: CGFloat = 0, priority: UILayoutPriority = .required)
+ -> NSLayoutConstraint
+ {
+ let res = anchor1.constraint(equalTo: anchor2, constant: constant)
+ res.priority = priority
+ return res
+ }
+
+ static func constraint>
+ (_ anchor1: T, to anchor2: T, smallestConstant: CGFloat, priority: UILayoutPriority = .required)
+ -> NSLayoutConstraint
+ {
+ let res = anchor1.constraint(greaterThanOrEqualTo: anchor2, constant: smallestConstant)
+ res.priority = priority
+ return res
+ }
+
+ static func constraint>
+ (_ anchor1: T, to anchor2: T, largestConstant: CGFloat, priority: UILayoutPriority = .required)
+ -> NSLayoutConstraint
+ {
+ let res = anchor1.constraint(lessThanOrEqualTo: anchor2, constant: largestConstant)
+ res.priority = priority
+ return res
+ }
+
+ func align>
+ (_ keyPath: KeyPath,
+ toSameAnchorOf view2: UIView, offsetBy offset: CGFloat = 0, priority: UILayoutPriority = .required)
+ -> NSLayoutConstraint
+ {
+ Self.constraint(self[keyPath: keyPath], to: view2[keyPath: keyPath], constant: offset, priority: priority)
+ }
+
+ private func align>
+ (_ keyPath: KeyPath,
+ toSameAnchorOf view2: UIView, offsetByAtLeast offset: CGFloat, priority: UILayoutPriority = .required)
+ -> NSLayoutConstraint
+ {
+ Self.constraint(self[keyPath: keyPath], to: view2[keyPath: keyPath], smallestConstant: offset, priority: priority)
+ }
+
+ func alignLeftEdge(to view2: UIView, insetBy inset: CGFloat = 0, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ align(\.leftAnchor, toSameAnchorOf: view2, offsetBy: inset, priority: priority)
+ }
+
+ func alignLeftEdge(toRightEdgeOf view2: UIView, offsetBy offset: CGFloat = 0, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ UIView.constraint(leftAnchor, to: view2.rightAnchor, constant: offset, priority: priority)
+ }
+
+ func alignLeftEdge(to view2: UIView, insetByAtLeast inset: CGFloat, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ align(\.leftAnchor, toSameAnchorOf: view2, offsetByAtLeast: inset, priority: priority)
+ }
+
+ func alignRightEdge(to view2: UIView, insetBy inset: CGFloat = 0, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ view2.align(\.rightAnchor, toSameAnchorOf: self, offsetBy: inset, priority: priority)
+ }
+
+ func alignRightEdge(to view2: UIView, insetByAtLeast inset: CGFloat, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ view2.align(\.rightAnchor, toSameAnchorOf: self, offsetByAtLeast: inset, priority: priority)
+ }
+
+ func alignRightEdge(toLeftEdgeOf view2: UIView, offsetBy offset: CGFloat, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ UIView.constraint(rightAnchor, to: view2.leftAnchor, constant: -offset, priority: priority)
+ }
+
+ func alignTopEdge(to view2: UIView, insetBy inset: CGFloat = 0, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ align(\.topAnchor, toSameAnchorOf: view2, offsetBy: inset, priority: priority)
+ }
+
+ func alignTopEdge(toBottomEdgeOf view2: UIView, offsetBy offset: CGFloat = 0, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ UIView.constraint(topAnchor, to: view2.bottomAnchor, constant: offset, priority: priority)
+ }
+
+ func alignTopEdge(toBottomEdgeOf view2: UIView, offsetByAtLeast offset: CGFloat, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ UIView.constraint(topAnchor, to: view2.bottomAnchor, smallestConstant: offset, priority: priority)
+ }
+
+ func alignTopEdge(toBottomEdgeOf view2: UIView, offsetByAtMost offset: CGFloat, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ UIView.constraint(topAnchor, to: view2.bottomAnchor, largestConstant: offset, priority: priority)
+ }
+
+ func alignTopEdge(toSafeAreaOf view2: UIView, insetBy inset: CGFloat = 0, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ UIView.constraint(topAnchor, to: view2.safeAreaLayoutGuide.topAnchor, constant: inset, priority: priority)
+ }
+
+ func alignTopEdge(toSafeAreaOf view2: UIView, insetByAtLeast inset: CGFloat, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ UIView.constraint(topAnchor, to: view2.safeAreaLayoutGuide.topAnchor, smallestConstant: inset, priority: priority)
+ }
+
+ func alignTopEdge(to view2: UIView, insetByAtLeast inset: CGFloat, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ align(\.topAnchor, toSameAnchorOf: view2, offsetByAtLeast: inset, priority: priority)
+ }
+
+ func alignBottomEdge(to view2: UIView, insetBy inset: CGFloat = 0, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ align(\.bottomAnchor, toSameAnchorOf: view2, offsetBy: -inset, priority: priority)
+ }
+
+ func alignBottomEdge(to view2: UIView, insetByAtLeast inset: CGFloat, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ view2.align(\.bottomAnchor, toSameAnchorOf: self, offsetByAtLeast: inset, priority: priority)
+ }
+
+ func alignBottomEdge(toTopEdgeOf view2: UIView, offsetBy offset: CGFloat = 0, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ UIView.constraint(bottomAnchor, to: view2.topAnchor, constant: offset, priority: priority)
+ }
+
+ func alignBottomEdge(toSafeAreaOf view2: UIView, insetBy inset: CGFloat = 0, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ UIView.constraint(bottomAnchor, to: view2.safeAreaLayoutGuide.bottomAnchor, constant: -inset, priority: priority)
+ }
+
+ func alignFirstBaseline(to view2: UIView, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ align(\.firstBaselineAnchor, toSameAnchorOf: view2, priority: priority)
+ }
+
+ func alignFirstBaseline(toBottomEdgeOf view2: UIView, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ UIView.constraint(firstBaselineAnchor, to: view2.bottomAnchor, priority: priority)
+ }
+
+ func alignHorizontalCenter(to view2: UIView) -> NSLayoutConstraint {
+ align(\.centerXAnchor, toSameAnchorOf: view2)
+ }
+
+ func alignVerticalCenter(to view2: UIView) -> NSLayoutConstraint {
+ align(\.centerYAnchor, toSameAnchorOf: view2)
+ }
+
+ func alignVerticalCenter(to view2: UIView, multiplier: CGFloat) -> NSLayoutConstraint {
+ NSLayoutConstraint(
+ item: self,
+ attribute: .centerY,
+ relatedBy: .equal,
+ toItem: view2,
+ attribute: .centerY,
+ multiplier: multiplier,
+ constant: 0
+ )
+ }
+
+ func alignCenter(to view2: UIView) -> [NSLayoutConstraint] {
+ [
+ align(\.centerXAnchor, toSameAnchorOf: view2),
+ align(\.centerYAnchor, toSameAnchorOf: view2)
+ ]
+ }
+
+ func align(to view2: UIView, insetBy insets: UIEdgeInsets = .zero) -> [NSLayoutConstraint] {
+ [
+ alignLeftEdge(to: view2, insetBy: insets.left),
+ alignRightEdge(to: view2, insetBy: insets.right),
+ alignTopEdge(to: view2, insetBy: insets.top),
+ alignBottomEdge(to: view2, insetBy: insets.bottom)
+ ]
+ }
+
+ func align(to rect: CGRect, in view: UIView) -> [NSLayoutConstraint] {
+ [
+ alignLeftEdge(to: view, insetBy: rect.minX),
+ alignTopEdge(to: view, insetBy: rect.minY),
+ constraintWidth(to: rect.width),
+ constraintHeight(to: rect.height)
+ ]
+ }
+
+ func alignVerticalEdges(to view2: UIView, insetBy insets: UIEdgeInsets = .zero) -> [NSLayoutConstraint] {
+ [
+ alignLeftEdge(to: view2, insetBy: insets.left),
+ alignRightEdge(to: view2, insetBy: insets.right)
+ ]
+ }
+
+ func alignHorizontalEdges(to view2: UIView, insetBy insets: UIEdgeInsets = .zero) -> [NSLayoutConstraint] {
+ [
+ alignTopEdge(to: view2, insetBy: insets.top),
+ alignBottomEdge(to: view2, insetBy: insets.bottom)
+ ]
+ }
+
+ func alignTopAndSides(to view2: UIView, insetBy insets: UIEdgeInsets = .zero) -> [NSLayoutConstraint] {
+ [
+ alignTopEdge(to: view2, insetBy: insets.top),
+ alignLeftEdge(to: view2, insetBy: insets.left),
+ alignRightEdge(to: view2, insetBy: insets.right)
+ ]
+ }
+
+ func alignBottomAndSides(to view2: UIView, insetBy insets: UIEdgeInsets = .zero) -> [NSLayoutConstraint] {
+ [
+ alignBottomEdge(to: view2, insetBy: insets.bottom),
+ alignLeftEdge(to: view2, insetBy: insets.left),
+ alignRightEdge(to: view2, insetBy: insets.right)
+ ]
+ }
+
+ func alignLeftAndSides(to view2: UIView, insetBy insets: UIEdgeInsets = .zero) -> [NSLayoutConstraint] {
+ [
+ alignLeftEdge(to: view2, insetBy: insets.left),
+ alignTopEdge(to: view2, insetBy: insets.top),
+ alignBottomEdge(to: view2, insetBy: insets.bottom)
+ ]
+ }
+
+ func alignRightAndSides(to view2: UIView, insetBy insets: UIEdgeInsets = .zero) -> [NSLayoutConstraint] {
+ [
+ alignRightEdge(to: view2, insetBy: insets.right),
+ alignTopEdge(to: view2, insetBy: insets.top),
+ alignBottomEdge(to: view2, insetBy: insets.bottom)
+ ]
+ }
+
+ func constraintHeight(to value: CGFloat, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ let res = heightAnchor.constraint(equalToConstant: value)
+ res.priority = priority
+ return res
+ }
+
+ func constraintHeight(toAtMost value: CGFloat) -> NSLayoutConstraint {
+ heightAnchor.constraint(lessThanOrEqualToConstant: value)
+ }
+
+ func constraintHeight(byAtLeast value: CGFloat) -> NSLayoutConstraint {
+ heightAnchor.constraint(greaterThanOrEqualToConstant: value)
+ }
+
+ func constraintAspectRatio(to value: CGFloat, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ let res = widthAnchor.constraint(equalTo: heightAnchor, multiplier: value)
+ res.priority = priority
+ return res
+ }
+
+ func constraintWidth(to value: CGFloat, priority: UILayoutPriority = .required) -> NSLayoutConstraint {
+ let res = widthAnchor.constraint(equalToConstant: value)
+ res.priority = priority
+ return res
+ }
+
+ func constraintWidth(toAtMost value: CGFloat) -> NSLayoutConstraint {
+ widthAnchor.constraint(lessThanOrEqualToConstant: value)
+ }
+
+ func centerHorzontally(minInset: CGFloat) -> [NSLayoutConstraint] {
+ guard let superview = superview else {
+ assertionFailure()
+ return []
+ }
+
+ return [
+ alignHorizontalCenter(to: superview),
+ alignLeftEdge(to: superview, insetByAtLeast: minInset, priority: .required)
+ ]
+ }
+
+}
+
+public extension Layout {
+ static func alignEdges(_ views: [UIView], constraint: (UIView, UIView) -> NSLayoutConstraint) -> [NSLayoutConstraint] {
+ views.first.map { first in
+ views.dropFirst().map {
+ constraint(first, $0)
+ }
+ } ?? []
+ }
+
+ static func alignEdges>
+ (_ views: [UIView], _ keyPath: KeyPath)
+ -> [NSLayoutConstraint]
+ {
+ alignEdges(views) { $0[keyPath: keyPath].constraint(equalTo: $1[keyPath: keyPath]) }
+ }
+
+ static func alignEdges(_ views: [UIView], _ constraints: [([UIView]) -> [NSLayoutConstraint]]) -> [NSLayoutConstraint] {
+ constraints.flatMap { $0(views) }
+ }
+
+ static func alignLeadingEdges(_ views: [UIView]) -> [NSLayoutConstraint] {
+ alignEdges(views, \.leadingAnchor)
+ }
+
+ static func alignTrailingEdges(_ views: [UIView]) -> [NSLayoutConstraint] {
+ alignEdges(views, \.trailingAnchor)
+ }
+
+ static func alignTopEdges(_ views: [UIView]) -> [NSLayoutConstraint] {
+ alignEdges(views, \.topAnchor)
+ }
+
+ static func alignBottomEdges(_ views: [UIView]) -> [NSLayoutConstraint] {
+ alignEdges(views, \.bottomAnchor)
+ }
+
+ static func alignVerticalEdges(_ views: [UIView]) -> [NSLayoutConstraint] {
+ alignEdges(views, [alignLeadingEdges, alignTrailingEdges])
+ }
+
+ static func alignHorizontalEdges(_ views: [UIView]) -> [NSLayoutConstraint] {
+ alignEdges(views, [alignTopEdges, alignBottomEdges])
+ }
+
+ static func alignHorizontalCenters(_ views: [UIView]) -> [NSLayoutConstraint] {
+ alignEdges(views, \.centerXAnchor)
+ }
+
+ static func alignVerticalCenters(_ views: [UIView]) -> [NSLayoutConstraint] {
+ alignEdges(views, \.centerYAnchor)
+ }
+
+ static func alignTopAndSides(_ views: [UIView]) -> [NSLayoutConstraint] {
+ alignEdges(views, [alignTopEdges, alignLeadingEdges, alignTrailingEdges])
+ }
+
+ static func alignBottomAndSides(_ views: [UIView]) -> [NSLayoutConstraint] {
+ alignEdges(views, [alignBottomEdges, alignLeadingEdges, alignTrailingEdges])
+ }
+}
+
+#endif
+
diff --git a/Sources/UIKitEx/Extensions/UIView.swift b/Sources/UIKitEx/Extensions/UIView.swift
new file mode 100644
index 0000000..80ea58f
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UIView.swift
@@ -0,0 +1,294 @@
+//
+// UIView.swift
+// Rocket Insights
+//
+// Created by Ashley Streb on 6/26/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import Functional
+import UIKit
+
+// MARK: - Styling
+
+public extension UIView {
+ func applyStyle(_ style: Styling.View) {
+ backgroundColor = .maybeColor(for: style.backgroundColor)
+ if let opacity = style.opacity {
+ alpha = opacity
+ }
+ layer.borderWidth = style.borderWidth ?? 0
+ layer.borderColor = UIColor.maybeColor(for: style.borderColor)?.cgColor
+ layer.cornerRadius = style.cornerRadius ?? 0
+ addShadow(style.shadow)
+ }
+
+ func addShadow(_ shadow: Styling.Shadow?) {
+ guard let shadow = shadow else {
+ layer.shadowOffset = .zero
+ layer.shadowRadius = 0
+ return
+ }
+
+ guard shadow.opacity > 0 else { return }
+ guard shadow.radius > 0 else { return }
+
+ if !(self is TextControl) {
+ layer.masksToBounds = false
+ }
+
+ layer.shadowOffset = shadow.offset
+ layer.shadowRadius = shadow.radius
+ layer.shadowOpacity = shadow.opacity
+ }
+
+ func removeShadow() {
+ addShadow(nil)
+ }
+
+ func applyGradient(_ gradient: Styling.LinearGradient) {
+ guard let gradientLayer = layer as? CAGradientLayer else {
+ assertionFailure("Expected CAGradientLayer, got \(type(of: layer))")
+ return
+ }
+
+ gradientLayer.colors = gradient.colors.map { UIColor.color(for: $0).cgColor }
+ gradientLayer.startPoint = gradient.startPoint
+ gradientLayer.endPoint = gradient.endPoint
+ }
+
+ func removeGradient() {
+ guard let gradientLayer = layer as? CAGradientLayer else {
+ assertionFailure("Expected CAGradientLayer, got \(type(of: layer))")
+ return
+ }
+
+ gradientLayer.colors = []
+ }
+}
+
+public extension UIView {
+ var isShown: Bool {
+ get { !isHidden }
+ set { isHidden = !newValue }
+ }
+}
+
+// MARK: - Separator
+
+public extension UIView {
+ var hasTopSeparator: Bool {
+ get { containsSeparator(.top) }
+ set { setSeparator(.top, enabled: newValue) }
+ }
+
+ var hasLeftSeparator: Bool {
+ get { containsSeparator(.left) }
+ set { setSeparator(.left, enabled: newValue) }
+ }
+
+ var hasBottomSeparator: Bool {
+ get { containsSeparator(.bottom) }
+ set { setSeparator(.bottom, enabled: newValue) }
+ }
+
+ var hasRightSeparator: Bool {
+ get { containsSeparator(.right) }
+ set { setSeparator(.right, enabled: newValue) }
+ }
+
+ enum SeparatorPlacement { case top, left, bottom, right }
+
+ class SeparatorView: UIView {
+ let placement: SeparatorPlacement
+
+ required init(placement: SeparatorPlacement, color: UIColor = .separator) {
+ self.placement = placement
+ super.init(frame: .zero)
+
+ backgroundColor = color
+ translatesAutoresizingMaskIntoConstraints = false
+ }
+
+ required init?(coder aDecoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ public override func didMoveToWindow() {
+ super.didMoveToWindow()
+ invalidateIntrinsicContentSize()
+ }
+
+ public override var intrinsicContentSize: CGSize {
+ let scale: CGFloat = traitCollection.displayScale
+ let thickness: CGFloat = 1.0 / scale
+ switch placement {
+ case .top, .bottom:
+ return CGSize(width: UIView.noIntrinsicMetric, height: thickness)
+ case .left, .right:
+ return CGSize(width: thickness, height: UIView.noIntrinsicMetric)
+ }
+ }
+ }
+
+ private func findSeparator(_ placement: SeparatorPlacement) -> SeparatorView? {
+ return subviews
+ .first { subview in
+ guard let separatorView = subview as? SeparatorView else { return false }
+ return placement == separatorView.placement
+ }
+ .map { $0 as! SeparatorView } // swiftlint:disable:this force_cast
+ }
+
+ func addSeparator(_ placement: SeparatorPlacement) {
+ guard !containsSeparator(placement) else { return }
+
+ let color: UIColor = .color(for: .separator)
+ let separatorView = SeparatorView(placement: placement, color: color)
+ addSubview(separatorView)
+
+ switch separatorView.placement {
+ case .top:
+ separatorView.alignTopAndSides(to: self).activate()
+
+ case .left:
+ separatorView.alignLeftAndSides(to: self).activate()
+
+ case .bottom:
+ separatorView.alignBottomAndSides(to: self).activate()
+
+ case .right:
+ separatorView.alignRightAndSides(to: self).activate()
+ }
+ }
+
+ func removeAllSeparators() {
+ removeSeparator(.top)
+ removeSeparator(.bottom)
+ removeSeparator(.left)
+ removeSeparator(.right)
+ }
+
+ private func containsSeparator(_ placement: SeparatorPlacement) -> Bool {
+ return findSeparator(placement) != nil
+ }
+
+ private func removeSeparator(_ placement: SeparatorPlacement) {
+ findSeparator(placement)?.removeFromSuperview()
+ }
+
+ private func setSeparator(_ placement: SeparatorPlacement, enabled: Bool) {
+ placement |> (enabled ? addSeparator : removeSeparator)
+ }
+}
+
+// MARK: - Chevron
+
+public extension UIView {
+ static func chevron() -> UIView {
+ let view = UIImageView()
+ view.image = UIImage(systemName: "chevron.right")
+ view.tintColor = .lightGray
+ view.contentMode = .center
+ return view
+ }
+}
+
+// MARK: - Common Utility Methods
+
+public extension UIView {
+ func scrollToVisible(makeTopVisible: Bool = true, animated: Bool = true) {
+ guard let scrollView: UIScrollView = enclosingSuperview() else { return }
+ var rect = convert(bounds, to: scrollView)
+ if makeTopVisible {
+ let maxVisibleHeight = scrollView.bounds.height
+ - scrollView.safeAreaInsets.top
+ - scrollView.safeAreaInsets.bottom
+ - scrollView.contentInset.bottom
+ rect.size.height = min(rect.size.height, maxVisibleHeight)
+ }
+ scrollView.scrollRectToVisible(rect, animated: animated)
+ }
+
+ func applyToSubtree(_ f: (UIView) -> Void) {
+ for view in subviews {
+ f(view)
+ view.applyToSubtree(f)
+ }
+ }
+
+ func recursivelyFindSubview(_ condition: (UIView) -> Bool) -> UIView? {
+ if condition(self) {
+ return self
+ }
+
+ for view in subviews {
+ if let found = view.recursivelyFindSubview(condition) {
+ return found
+ }
+ }
+
+ return nil
+ }
+
+ func recursivelyFindSubview() -> T? {
+ recursivelyFindSubview { $0 is T } as? T
+ }
+
+ func enclosingSuperview() -> T? {
+ return sequence(first: self, next: { $0.superview }).first { $0 is T }.flatMap { $0 as? T }
+ }
+
+ static func resourceImage(named name: String) -> UIImage? {
+ return UIImage(named: name, in: Bundle(for: self), compatibleWith: nil)
+ }
+
+ func moveUnderSuperview() {
+ guard let superview = superview else { return }
+ layoutIfNeeded()
+ let shadowDistance = layer.shadowRadius + abs(layer.shadowOffset.height)
+ transform = .init(translationX: 0, y: superview.bounds.height - frame.minY + shadowDistance)
+ }
+
+ func restoreLocation() {
+ transform = .identity
+ }
+}
+
+// MARK: - First Responder
+
+public extension UIView {
+ func currentFirstResponder() -> UIView? {
+ if isFirstResponder {
+ return self
+ }
+
+ for view in subviews {
+ if let view = view.currentFirstResponder() {
+ return view
+ }
+ }
+
+ return nil
+ }
+
+ func postDidBecomeFirstResponderNotification() {
+ NotificationCenter.default.post(name: .didBecomeFirstResponder, object: self)
+ }
+
+ /// Resigns the first responder first to avoid auto scrolling.
+ /// This helps avoid scrolling to the wrong place when a text form item
+ /// has additional elements.
+ static func changeFirstResponder(from: UIView?, to: UIView?) {
+ from?.resignFirstResponder()
+ to?.becomeFirstResponder()
+ }
+}
+
+public extension NSNotification.Name {
+ static let didBecomeFirstResponder = NSNotification.Name("didBecomeFirstResponderName")
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UIViewController.swift b/Sources/UIKitEx/Extensions/UIViewController.swift
new file mode 100644
index 0000000..bb7a864
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UIViewController.swift
@@ -0,0 +1,98 @@
+//
+// UIViewController.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 6/22/17.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension UIViewController {
+ class func fromStoryboard(_ storyboardName: String? = nil) -> T {
+ let name = storyboardName ?? String(describing: self)
+ guard let vc = UIStoryboard(name: name, bundle: nil).instantiateInitialViewController() as? T else {
+ fatalError("Cannot load from storyboard \(name)")
+ }
+
+ return vc
+ }
+
+ /// Matches UIViewController.show()
+ @objc func hide() {
+ if let navigationController = parent as? UINavigationController {
+ let all = navigationController.viewControllers
+ if let prevVC = zip(all, all.dropFirst()).first(where: { (_, next) in next === self })?.0 {
+ (self as? BasicViewController)?.endValue = .fromUI
+ navigationController.popToViewController(prevVC, animated: true)
+ }
+ else if let flowVC = self as? AppFlowViewController {
+ flowVC.cancel()
+ }
+ else {
+ navigationController.dismiss(animated: true)
+ }
+ }
+ else if let flowVC = self as? AppFlowViewController {
+ flowVC.cancel()
+ }
+ else {
+ (self as? BasicViewController)?.endValue = .fromUI
+ dismiss(animated: true)
+ }
+ }
+
+ func replace(by vc: UIViewController) {
+ guard let navigationController = navigationController else {
+ fatalError("Expected a valid navigation controller")
+ }
+
+ (self as? BasicViewController)?.endValue = .fromCode
+
+ let maybeSnapshotView = view.snapshotView(afterScreenUpdates: true)
+
+ // pop then push doesn't work if there is only one controller
+ var viewControllers = navigationController.viewControllers
+ viewControllers[viewControllers.count - 1] = vc
+ navigationController.viewControllers = viewControllers
+
+ guard let snapshotView = maybeSnapshotView else { return }
+ snapshotView.translatesAutoresizingMaskIntoConstraints = false
+ vc.view.addSubview(snapshotView)
+ snapshotView.align(to: vc.view).activate()
+
+ UIView.animate(
+ withDuration: 0.2,
+ delay: 0,
+ usingSpringWithDamping: 1.0,
+ initialSpringVelocity: 0,
+ options: [],
+ animations: {
+ snapshotView.alpha = 0
+ },
+ completion: { _ in
+ snapshotView.removeFromSuperview()
+ }
+ )
+ }
+
+ func isChild(of nc: UINavigationController) -> Bool {
+ nc.viewControllers.contains(self)
+ }
+
+ func child(of nc: UINavigationController) -> UIViewController? {
+ if self === nc {
+ return nil
+ }
+ else if isChild(of: nc) {
+ return self
+ }
+ else {
+ return parent?.child(of: nc)
+ }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UIViewControllerPreview.swift b/Sources/UIKitEx/Extensions/UIViewControllerPreview.swift
new file mode 100644
index 0000000..7e7176d
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UIViewControllerPreview.swift
@@ -0,0 +1,57 @@
+//
+// UIViewControllerPreview.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 7/14/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+import SwiftUI
+
+struct ViewController_Preview: PreviewProvider {
+ private class ReplacedVC: UIViewController {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.backgroundColor = .lightGray
+
+ let label = UILabel()
+ label.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(label)
+ label.text = "Replaced Content"
+
+ label.alignCenter(to: view).activate()
+ }
+ }
+
+ private class StartVC: UIViewController {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.backgroundColor = .white
+
+ let button = UIButton()
+ button.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(button)
+ button.setTitle("Repace Content", for: .normal)
+ button.addTarget(self, action: #selector(replace(_:)), for: .touchUpInside)
+
+ button.alignCenter(to: view).activate()
+ }
+
+ @objc func replace(_ sender: Any) {
+ replace(by: ReplacedVC())
+ }
+ }
+
+ static var previews: some View {
+ UIViewControllerPreview {
+ UINavigationController(rootViewController: StartVC())
+ }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Extensions/UNUserNotificationCenter.swift b/Sources/UIKitEx/Extensions/UNUserNotificationCenter.swift
new file mode 100644
index 0000000..daec501
--- /dev/null
+++ b/Sources/UIKitEx/Extensions/UNUserNotificationCenter.swift
@@ -0,0 +1,27 @@
+//
+// UNUserNotificationCenter.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 2/14/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import Foundation
+import Combine
+import CombineEx
+import UserNotifications
+
+public extension UNUserNotificationCenter {
+ func getAccess(options: UNAuthorizationOptions = []) -> LazyFuture {
+ .init { [weak self] promise in
+ guard let self = self else { return promise(.success(false)) }
+ self.requestAuthorization(options: options) { granted, _ in
+ promise(.success(granted))
+ }
+ }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/InputViews.swift b/Sources/UIKitEx/InputViews.swift
new file mode 100644
index 0000000..be4f0f9
--- /dev/null
+++ b/Sources/UIKitEx/InputViews.swift
@@ -0,0 +1,62 @@
+//
+// InputViews.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 8/22/17.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public protocol InputView: UIView {
+ var nextInputView: InputView? { get set }
+ var previousInputView: InputView? { get set }
+ @discardableResult func becomeFirstResponder() -> Bool
+}
+
+public extension UIView {
+ private func subtreeInputViews() -> [InputView] {
+ if let inputView = self as? InputView {
+ return inputView.isHidden ? [] : [inputView]
+ }
+
+ return subviews.flatMap { $0.subtreeInputViews() }
+ }
+
+ func connectInputViews() {
+ let inputViews = subtreeInputViews()
+ for (prev, next) in zip(inputViews, inputViews.dropFirst()) {
+ prev.nextInputView = next
+ next.previousInputView = prev
+ }
+ }
+}
+
+extension UIResponder {
+ @IBAction open func dismissKeyboardAndScrollContainerToEnd() {
+ var resignedFirstResponder = false
+ CATransaction.begin()
+ CATransaction.setCompletionBlock {
+ if resignedFirstResponder {
+ // without calling scrollContainerToEnd() on the next
+ // run loop iteration, the scrollview bottom inset becomes
+ // invalid until the next scroll
+ DispatchQueue.main.async {
+ self.scrollContainerToEnd()
+ }
+ }
+ }
+ resignedFirstResponder = resignFirstResponder()
+ CATransaction.commit()
+ }
+
+ private func scrollContainerToEnd() {
+ guard let view = self as? UIView else { return }
+ guard let rootScrollView: RootScrollView = view.enclosingSuperview() else { return }
+ rootScrollView.scrollToEnd()
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/KeyboardManager.swift b/Sources/UIKitEx/KeyboardManager.swift
new file mode 100644
index 0000000..865d191
--- /dev/null
+++ b/Sources/UIKitEx/KeyboardManager.swift
@@ -0,0 +1,80 @@
+//
+// KeyboardManager.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 11/15/17.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+class KeyboardManager {
+ private var savedBottomInset: CGFloat = 0
+ private var keyboardBottomInset: CGFloat = 0
+ private var isKeyboardShown = false
+
+ func trackKeyboard(enabled: Bool) {
+ let nc = NotificationCenter.default
+ if enabled {
+ nc.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
+ nc.addObserver(self, selector: #selector(keyboardDidHide), name: UIResponder.keyboardDidHideNotification, object: nil)
+ nc.addObserver(self, selector: #selector(keyboardDidChangeFrame), name: UIResponder.keyboardDidChangeFrameNotification, object: nil)
+ }
+ else {
+ nc.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
+ nc.removeObserver(self, name: UIResponder.keyboardDidHideNotification, object: nil)
+ nc.removeObserver(self, name: UIResponder.keyboardDidChangeFrameNotification, object: nil)
+ }
+ }
+
+ weak var scrollView: UIScrollView?
+
+ private func applyBottomInset(_ inset: CGFloat) {
+ guard let scrollView = scrollView else { return }
+ scrollView.contentInset.bottom = inset
+ scrollView.scrollIndicatorInsets = scrollView.contentInset
+ }
+
+ func applyKeyboardBottomInset() {
+ applyBottomInset(keyboardBottomInset)
+ }
+
+ private func applySavedBottomInset() {
+ applyBottomInset(savedBottomInset)
+ }
+
+ private func updateKeyboardBottomInset(keyboardRect: CGRect) {
+ guard let scrollView = scrollView else { return }
+ guard let window = scrollView.window else { return }
+ let offsetFromBottom = window.bounds.maxY - window.convert(scrollView.bounds, from: scrollView).maxY
+ keyboardBottomInset = keyboardRect.height - offsetFromBottom - scrollView.safeAreaInsets.bottom
+ }
+
+ @objc private func keyboardWillShow(_ notification: NSNotification) {
+ guard let scrollView = scrollView else { return }
+ guard let userInfo = notification.userInfo else { return }
+ guard let keyboardRect = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
+
+ if !isKeyboardShown {
+ savedBottomInset = scrollView.contentInset.bottom
+ }
+ updateKeyboardBottomInset(keyboardRect: keyboardRect)
+ isKeyboardShown = true
+ }
+
+ @objc private func keyboardDidHide(_ notification: NSNotification) {
+ applySavedBottomInset()
+ keyboardBottomInset = savedBottomInset
+ isKeyboardShown = false
+ }
+
+ @objc private func keyboardDidChangeFrame(_ notification: NSNotification) {
+ guard let userInfo = notification.userInfo else { return }
+ guard let keyboardRect = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
+ updateKeyboardBottomInset(keyboardRect: keyboardRect)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Styling/ColorStyling.swift b/Sources/UIKitEx/Styling/ColorStyling.swift
new file mode 100644
index 0000000..bc37a25
--- /dev/null
+++ b/Sources/UIKitEx/Styling/ColorStyling.swift
@@ -0,0 +1,97 @@
+//
+// ColorStyling.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/18/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+import CoreGraphics
+
+public extension Styling {
+ struct Color {
+ public var red, green, blue, opacity: CGFloat
+
+ public var htmlHex: String {
+ func intVal(_ arg: CGFloat) -> Int {
+ Int(round(arg * 255))
+ }
+
+ if opacity != 1 {
+ return String(format: "#%02lX%02lX%02lX%02lX", intVal(red), intVal(green), intVal(blue), intVal(opacity))
+ } else {
+ return String(format: "#%02lX%02lX%02lX", intVal(red), intVal(green), intVal(blue))
+ }
+ }
+ }
+}
+
+public extension Styling.Color {
+ static let clear: Self = .literal(hex: "000000", opacity: 0)
+ static let white: Self = "FFFFFF"
+ static let black: Self = "000000"
+ static let separator: Self = .literal(hex: "69737B", opacity: 0.2)
+ static let dimmed: Self = .literal(hex: "000000", opacity: 0.4)
+}
+
+extension Styling.Color: ExpressibleByStringLiteral {
+ public enum ParseHexError: Error {
+ case mustStartWithHash
+ case invalidCharCount
+ case invalidComponentFormat
+ case invalidArgs
+ }
+
+ public init(hex: String, opacity: CGFloat? = nil) throws {
+ func getComponent(_ remaining: Substring) throws -> (CGFloat, Substring) {
+ let componentHex = remaining.prefix(2)
+ guard componentHex.count == 2 else {
+ throw ParseHexError.invalidComponentFormat
+ }
+ guard let value = Int(componentHex, radix: 16) else {
+ throw ParseHexError.invalidComponentFormat
+ }
+ return (CGFloat(value) / 255.0, remaining.dropFirst(2))
+ }
+
+ guard hex.count >= 6 else {
+ throw ParseHexError.invalidCharCount
+ }
+
+ var remaining = hex[...]
+ if remaining.starts(with: "#") {
+ remaining = remaining.dropFirst()
+ }
+
+ (red, remaining) = try getComponent(remaining)
+ (green, remaining) = try getComponent(remaining)
+ (blue, remaining) = try getComponent(remaining)
+ if remaining.isEmpty {
+ self.opacity = opacity ?? 1
+ }
+ else {
+ guard opacity == nil else {
+ throw ParseHexError.invalidArgs
+ }
+ (self.opacity, remaining) = try getComponent(remaining)
+ }
+ }
+
+ public static func literal(hex: String, opacity: CGFloat) -> Self {
+ do {
+ return try .init(hex: hex, opacity: opacity)
+ }
+ catch {
+ fatalError("\(error)")
+ }
+ }
+
+ public init(stringLiteral hex: String) {
+ do {
+ self = try .init(hex: String(hex))
+ }
+ catch {
+ fatalError("\(error)")
+ }
+ }
+}
diff --git a/Sources/UIKitEx/Styling/FontStyling.swift b/Sources/UIKitEx/Styling/FontStyling.swift
new file mode 100644
index 0000000..c73c9c7
--- /dev/null
+++ b/Sources/UIKitEx/Styling/FontStyling.swift
@@ -0,0 +1,29 @@
+//
+// FontStyling.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/19/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public struct FontFaceInfo {
+ public let fontFamily: String
+ public let fileName: String
+
+ public init(fontFamily: String, fileName: String) {
+ self.fontFamily = fontFamily
+ self.fileName = fileName
+ }
+}
+
+public protocol FontStyling {
+ static func font(for style: Self) -> UIFont
+ static func fontFaces() -> [FontFaceInfo]
+ static func `default`(_ traits: Styling.Traits) -> Self
+}
+
+#endif
diff --git a/Sources/UIKitEx/Styling/HtmlStyling.swift b/Sources/UIKitEx/Styling/HtmlStyling.swift
new file mode 100644
index 0000000..d29b32d
--- /dev/null
+++ b/Sources/UIKitEx/Styling/HtmlStyling.swift
@@ -0,0 +1,88 @@
+//
+// HtmlStyling.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 10/2/17.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import Foundation
+
+public class HtmlText {
+ private var html = ""
+ private var indent = ""
+ private var indentUnit = " "
+ private let indentUnitLength = 2
+
+ public init(html: String = "", indent: String = "", indentUnit: String = " ") {
+ self.html = html
+ self.indent = indent
+ self.indentUnit = indentUnit
+ }
+
+ public func shiftRight() {
+ indent.append(indentUnit)
+ }
+
+ public func shiftLeft() {
+ indent.removeLast(indentUnitLength)
+ }
+
+ public func addLine(_ line: String) {
+ html.append("\(indent)\(line)\n")
+ }
+
+ public func openTag(_ tag: String) {
+ addLine("<\(tag)>")
+ shiftRight()
+ }
+
+ public func closeTag(_ tag: String) {
+ shiftLeft()
+ addLine("\(tag)>")
+ }
+
+ public func addStyle(_ tag: String, _ content: [String]) {
+ addLine("\(tag) {")
+ shiftRight()
+ content.forEach { addLine($0) }
+ shiftLeft()
+ addLine("}")
+ }
+
+ public func addHtmlTag(_ tag: String, _ content: (HtmlText) -> Void) {
+ openTag(tag)
+ content(self)
+ closeTag(tag)
+ }
+
+ public func addText(_ text: String) {
+ html.append(text)
+ html.append("\n")
+ }
+
+ public var value: String {
+ return html
+ }
+}
+
+public extension HtmlText {
+ func addViewport() {
+ addLine("")
+ }
+
+ func addAppFonts(stylingType: T.Type) {
+ for fontFaceInfo in stylingType.fontFaces() {
+ addStyle(
+ "@font-face", [
+ "font-family: \(fontFaceInfo.fontFamily);",
+ "src: url(\(fontFaceInfo.fileName));"
+ ]
+ )
+ }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Styling/NSTextAlignment.swift b/Sources/UIKitEx/Styling/NSTextAlignment.swift
new file mode 100644
index 0000000..0bb6bd7
--- /dev/null
+++ b/Sources/UIKitEx/Styling/NSTextAlignment.swift
@@ -0,0 +1,23 @@
+//
+// NSTextAlignment.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 6/21/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public extension NSTextAlignment {
+ static func alignment(for alignment: Styling.Text.Alignment?) -> NSTextAlignment? {
+ guard let alignment = alignment else { return nil }
+ switch alignment {
+ case .left: return .left
+ case .center: return .center
+ }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Styling/String+UIKit.swift b/Sources/UIKitEx/Styling/String+UIKit.swift
new file mode 100644
index 0000000..976a6ce
--- /dev/null
+++ b/Sources/UIKitEx/Styling/String+UIKit.swift
@@ -0,0 +1,116 @@
+//
+// String+UIKit.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/19/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import FoundationEx
+import UIKit
+
+public extension String {
+ func styled(as style: Styling.Text, isHTML: Bool = false) -> NSAttributedString {
+ styledText(
+ font: F.font(for: style.font),
+ charSpacing: style.charSpacing,
+ lineSpacing: style.lineSpacing,
+ color: .maybeColor(for: style.color),
+ alignment: .alignment(for: style.alignment),
+ topOffset: style.topOffset,
+ isHTML: isHTML
+ )
+ }
+
+ func styledText(
+ font: UIFont,
+ charSpacing: CGFloat? = nil,
+ lineSpacing: CGFloat? = nil,
+ color: UIColor? = nil,
+ alignment: NSTextAlignment? = nil,
+ topOffset: CGFloat? = nil,
+ isHTML: Bool = false
+ )
+ -> NSAttributedString
+ {
+ let attributes = String.styledTextAttributes(
+ font: font,
+ charSpacing: charSpacing,
+ lineSpacing: lineSpacing,
+ color: color,
+ alignment: alignment,
+ topOffset: topOffset
+ )
+
+ func basicAttributedString() -> NSAttributedString {
+ return NSAttributedString(string: self, attributes: attributes)
+ }
+
+ guard isHTML else {
+ return basicAttributedString()
+ }
+
+ let encoding: String.Encoding = .utf8
+ guard let data = data(using: encoding, allowLossyConversion: true) else {
+ return basicAttributedString()
+ }
+
+ guard let attributedString = (maybe {
+ try NSMutableAttributedString(
+ data: data,
+ options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: encoding.rawValue],
+ documentAttributes: nil
+ )
+ })
+ else {
+ return basicAttributedString()
+ }
+
+ let fullStringRange = NSRange(location: 0, length: attributedString.length)
+ attributedString.addAttributes(attributes, range: fullStringRange)
+ return attributedString
+ }
+
+ static func styledTextAttributes(
+ font: UIFont,
+ charSpacing: CGFloat? = nil,
+ lineSpacing: CGFloat? = nil,
+ color: UIColor? = nil,
+ alignment: NSTextAlignment? = nil,
+ topOffset: CGFloat? = nil
+ )
+ -> [NSAttributedString.Key: Any]
+ {
+ var attributes: [NSAttributedString.Key: Any] = [:]
+
+ attributes[.font] = font
+
+ if let charSpacing = charSpacing {
+ attributes[.kern] = charSpacing
+ }
+
+ let paragraphStyle = NSMutableParagraphStyle()
+ if let lineSpacing = lineSpacing {
+ paragraphStyle.minimumLineHeight = lineSpacing
+ paragraphStyle.maximumLineHeight = lineSpacing
+ paragraphStyle.lineBreakMode = .byTruncatingTail
+ }
+ if let alignment = alignment {
+ paragraphStyle.alignment = alignment
+ }
+ if let topOffset = topOffset {
+ paragraphStyle.paragraphSpacingBefore = topOffset
+ }
+ attributes[.paragraphStyle] = paragraphStyle
+
+ if let color = color {
+ attributes[.foregroundColor] = color
+ }
+
+ return attributes
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Styling/Styling.swift b/Sources/UIKitEx/Styling/Styling.swift
new file mode 100644
index 0000000..42a4cd4
--- /dev/null
+++ b/Sources/UIKitEx/Styling/Styling.swift
@@ -0,0 +1,92 @@
+//
+// Styling.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/18/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+import Foundation
+import CoreGraphics
+
+public struct Styling {
+ public static var standardNavigationDelay: TimeInterval = 0.5
+ public static var standardPresentationDelay: TimeInterval = 0.1
+
+ public enum ComponentSize {
+ case compact, regular
+ }
+
+ public struct Traits {
+ public let componentSize: ComponentSize
+ public let separatorThickness: CGFloat
+
+ public init(componentSize: Styling.ComponentSize, separatorThickness: CGFloat) {
+ self.componentSize = componentSize
+ self.separatorThickness = separatorThickness
+ }
+ }
+
+ public struct EdgeInsets {
+ public init(top: CGFloat, left: CGFloat, bottom: CGFloat, right: CGFloat) {
+ self.top = top
+ self.left = left
+ self.bottom = bottom
+ self.right = right
+ }
+
+ public var top, left, bottom, right: CGFloat
+ }
+
+ public struct Shadow {
+ public init(offset: CGSize, radius: CGFloat, opacity: Float) {
+ self.offset = offset
+ self.radius = radius
+ self.opacity = opacity
+ }
+
+ public var offset: CGSize
+ public var radius: CGFloat
+ public var opacity: Float
+ }
+
+ public struct View {
+ public init(width: CGFloat? = nil, height: CGFloat? = nil, backgroundColor: Styling.Color? = nil, opacity: CGFloat? = nil, borderWidth: CGFloat? = nil, borderColor: Styling.Color? = nil, cornerRadius: CGFloat? = nil, shadow: Styling.Shadow? = nil) {
+ self.width = width
+ self.height = height
+ self.backgroundColor = backgroundColor
+ self.opacity = opacity
+ self.borderWidth = borderWidth
+ self.borderColor = borderColor
+ self.cornerRadius = cornerRadius
+ self.shadow = shadow
+ }
+
+ public var width: CGFloat?
+ public var height: CGFloat?
+ public var backgroundColor: Color?
+ public var opacity: CGFloat?
+ public var borderWidth: CGFloat?
+ public var borderColor: Color?
+ public var cornerRadius: CGFloat?
+ public var shadow: Shadow?
+ }
+
+ public struct LinearGradient {
+ public init(colors: [Styling.Color], startPoint: CGPoint, endPoint: CGPoint) {
+ self.colors = colors
+ self.startPoint = startPoint
+ self.endPoint = endPoint
+ }
+
+ var colors: [Color]
+ var startPoint: CGPoint
+ var endPoint: CGPoint
+ }
+}
+
+extension Styling.Traits: Equatable {}
+
+public extension Styling.EdgeInsets {
+ static let zero: Self = .init(top: 0, left: 0, bottom: 0, right: 0)
+}
diff --git a/Sources/UIKitEx/Styling/TextStyling.swift b/Sources/UIKitEx/Styling/TextStyling.swift
new file mode 100644
index 0000000..f2cfbc8
--- /dev/null
+++ b/Sources/UIKitEx/Styling/TextStyling.swift
@@ -0,0 +1,44 @@
+//
+// TextStyling.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 3/19/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import Foundation
+import CoreGraphics
+
+public extension Styling {
+ struct Text {
+ public enum Alignment {
+ case left
+ case center
+ }
+
+ public let font: F
+ public var charSpacing: CGFloat?
+ public var lineSpacing: CGFloat?
+ public var color: Color?
+ public var alignment: Alignment?
+ public var topOffset: CGFloat?
+ public var shadow: Shadow?
+
+ public init(font: F, charSpacing: CGFloat? = nil, lineSpacing: CGFloat? = nil, color: Styling.Color? = nil, alignment: Styling.Text.Alignment? = nil, topOffset: CGFloat? = nil, shadow: Styling.Shadow? = nil) {
+ self.font = font
+ self.charSpacing = charSpacing
+ self.lineSpacing = lineSpacing
+ self.color = color
+ self.alignment = alignment
+ self.topOffset = topOffset
+ self.shadow = shadow
+ }
+ }
+
+ typealias TextFunc = (Traits) -> Text
+ typealias AttributedTextFunc = (Traits) -> NSAttributedString
+}
+
+#endif
diff --git a/Sources/UIKitEx/URLDataPublisher.swift b/Sources/UIKitEx/URLDataPublisher.swift
new file mode 100644
index 0000000..8258f55
--- /dev/null
+++ b/Sources/UIKitEx/URLDataPublisher.swift
@@ -0,0 +1,26 @@
+//
+// URLDataPublisher.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 6/17/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+import Foundation
+import Combine
+import CombineEx
+
+public struct URLDataPublisher {
+ public typealias Publisher = AnySingleValuePublisher<(data: Data, response: URLResponse), URLError>
+ var data: (_ request: URLRequest) -> Publisher
+
+ public func callAsFunction(for request: URLRequest) -> Publisher {
+ data(request)
+ }
+}
+
+public extension URLDataPublisher {
+ static let `default` = URLDataPublisher { request in
+ URLSession.shared.dataTaskPublisher(for: request).eraseType()
+ }
+}
diff --git a/Sources/UIKitEx/ViewControllers/BasicViewController.swift b/Sources/UIKitEx/ViewControllers/BasicViewController.swift
new file mode 100644
index 0000000..7ea5378
--- /dev/null
+++ b/Sources/UIKitEx/ViewControllers/BasicViewController.swift
@@ -0,0 +1,157 @@
+//
+// BasicViewController.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 03/30/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+import Combine
+import CombineEx
+import ReducerArchitecture
+
+open class BasicViewController: UIViewController {
+ open var viewClass = UIKitEx.env.vcViewClass
+
+ public private(set) var scrollView: RootScrollView?
+ public private(set) var contentView: UIView?
+
+ public private(set) var availableSize: CGSize = .zero
+
+ // MARK: - Common Elements
+
+ private var _ended = PassthroughSubject()
+ public var ended: AnySingleValuePublisher {
+ _ended.first().eraseType()
+ }
+
+ public var endValue: UIEndValue = .fromUI
+ // only the cancellation of the first VC in the flow should really cancel the flow
+ open var ignoreCancel = true
+
+ public private(set) var appearanceCount = 0
+ public private(set) var isLayoutConfigured = false
+
+ public var isFirstAppearance: Bool {
+ appearanceCount == 1
+ }
+
+ @discardableResult
+ public func makeContentScrollable() -> UIView {
+ let scrollView = RootScrollView()
+ self.scrollView = scrollView
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(scrollView)
+
+ let contentView = UIView()
+ self.contentView = contentView
+ contentView.translatesAutoresizingMaskIntoConstraints = false
+ scrollView.addSubview(contentView)
+
+ var layout = Layout()
+ layout.add(scrollView.align(to: view))
+ layout.add(contentView.align(to: scrollView))
+ layout.add(UIView.constraint(contentView.widthAnchor, to: view.widthAnchor))
+ layout.activate()
+
+ return contentView
+ }
+
+ // MARK: - Layout
+
+ // override in subclasses
+ /// Returns the main content view. Override in subclasses
+ @discardableResult
+ open func updateMainContentLayout(traits: Styling.Traits, availableSize: CGSize) -> UIView? {
+ return nil
+ }
+
+ private func updateMainContentLayoutIfNeeded() {
+ guard availableSize.width > 0 else { return }
+ updateMainContentLayout(traits: .traits(traitCollection), availableSize: availableSize)
+ }
+
+ open override func viewWillLayoutSubviews() {
+ availableSize = view.bounds.size
+ updateMainContentLayoutIfNeeded()
+ super.viewWillLayoutSubviews()
+ }
+
+ open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+ if let vc = self as? ConfigurableViewController {
+ guard isLayoutConfigured else { return }
+ vc.updateLayout()
+ updateMainContentLayoutIfNeeded()
+ }
+ else {
+ updateMainContentLayoutIfNeeded()
+ }
+ }
+
+ open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
+ super.viewWillTransition(to: size, with: coordinator)
+ guard isViewLoaded else { return }
+
+ availableSize = size
+ let traits: Styling.Traits = .traits(traitCollection)
+ coordinator.animate(alongsideTransition: { [weak self] _ in
+ guard let self = self else { return }
+ (self as? ConfigurableViewController)?.updateLayout()
+ self.updateMainContentLayout(traits: traits, availableSize: size)?.layoutIfNeeded()
+ })
+ }
+
+ // MARK: - Comon Callbacks
+
+ open override func loadView() {
+ view = viewClass.init(frame: .zero)
+ }
+
+ open override func viewDidLoad() {
+ super.viewDidLoad()
+ (self as? ConfigurableViewController)?.configure()
+ }
+
+ open override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+
+ if appearanceCount == 0, let vc = self as? ConfigurableViewController {
+ vc.configureLayout()
+ isLayoutConfigured = true
+ vc.configureAfterLayout()
+ vc.updateLayout()
+ }
+ }
+
+ open override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ scrollView?.trackKeyboard(enabled: true)
+ appearanceCount += 1
+ }
+
+ open override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ scrollView?.trackKeyboard(enabled: false)
+ }
+
+ open override func didMove(toParent parent: UIViewController?) {
+ super.didMove(toParent: parent)
+ if parent == nil {
+ _ended.send(endValue)
+ }
+ }
+
+ open override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
+ let dismissingSelf = (presentingViewController != nil)
+ if dismissingSelf {
+ _ended.send(endValue)
+ }
+ super.dismiss(animated: flag, completion: completion)
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/ViewControllers/ConfigurableViewController.swift b/Sources/UIKitEx/ViewControllers/ConfigurableViewController.swift
new file mode 100644
index 0000000..1c53d55
--- /dev/null
+++ b/Sources/UIKitEx/ViewControllers/ConfigurableViewController.swift
@@ -0,0 +1,33 @@
+//
+// ConfigurableViewController.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 03/30/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public protocol ConfigurableViewController: UIViewController {
+ /// Configures the data flow and the view hierarchy
+ func configure()
+
+ /// Configures the layout but assumes a call to `updateLayout` to finish setup
+ func configureLayout()
+
+ /// Updates the layout for the current traits
+ func updateLayout()
+
+ /// Configures the rest of the UI after initial layout.
+ /// (necessary on iOS 13 for UICollectionView)
+ func configureAfterLayout()
+}
+
+public extension ConfigurableViewController {
+ func configureAfterLayout() {
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/ViewControllers/ReducerArchitectureVC.swift b/Sources/UIKitEx/ViewControllers/ReducerArchitectureVC.swift
new file mode 100644
index 0000000..a13680e
--- /dev/null
+++ b/Sources/UIKitEx/ViewControllers/ReducerArchitectureVC.swift
@@ -0,0 +1,63 @@
+//
+// ReducerArchitectureVC.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 03/30/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+import Combine
+import CombineEx
+import ReducerArchitecture
+
+public protocol ReducerArchitectureVC: BasicReducerArchitectureVC, ConfigurableViewController, AsyncValueUI {
+ func configureData()
+ func configureViewHierarchy()
+ func configureLayout()
+ func updateLayout()
+ func connectToStore()
+ func connectToStoreAfterLayout()
+}
+
+public extension ReducerArchitectureVC {
+ func configureData() {
+ }
+
+ func connectToStoreAfterLayout() {
+ }
+
+ func configure() {
+ configureData()
+ configureViewHierarchy()
+ connectToStore()
+ // layout configuration is separate to avoid
+ // unwanted animations on first view appearance
+ }
+
+ func configureAfterLayout() {
+ connectToStoreAfterLayout()
+ }
+}
+
+public extension ReducerArchitectureVC where Self: BasicViewController {
+ var value: AnyPublisher {
+ store.value.merge(
+ with: ended
+ .filter { [unowned self] in
+ $0.isFromUI && !self.ignoreCancel
+ }
+ .tryMap { _ in
+ // print("cancel: \(self.store.identifier)")
+ throw Cancel.cancel
+ }
+ .forceErrorType(Cancel.self)
+ )
+ // .print(self.store.identifier)
+ .eraseToAnyPublisher()
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Views/BottomDrawer.swift b/Sources/UIKitEx/Views/BottomDrawer.swift
new file mode 100644
index 0000000..e0edee5
--- /dev/null
+++ b/Sources/UIKitEx/Views/BottomDrawer.swift
@@ -0,0 +1,134 @@
+//
+// BottomDrawerView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 11/12/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+open class BottomDrawer: FloatingCard {
+ private var handle: UIView?
+ private var handleContainerView: UIView?
+ private var closeButton: UIButton?
+
+ private var closeAction: (() -> Void)?
+
+ @objc private func close(_ sender: Any) {
+ closeAction?()
+ }
+
+ public init(
+ _ contentView: T,
+ showHandle: Bool = false,
+ closeAction: (() -> Void)? = nil,
+ styleFunc: @escaping (Styling.Traits) -> Styling.FloatingCard
+ ) {
+ self.closeAction = closeAction
+ super.init(contentView, styleFunc: styleFunc)
+
+ let handleSize = CGSize(width: 40, height: 5)
+ if showHandle {
+ let handleContainerView = PassthroughView()
+ self.handleContainerView = handleContainerView
+ handleContainerView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(handleContainerView)
+
+ let handle = UIView()
+ self.handle = handle
+ handle.translatesAutoresizingMaskIntoConstraints = false
+ handleContainerView.addSubview(handle)
+ handle.layer.backgroundColor = UIColor(white: 0, alpha: 0.2).cgColor
+ handle.layer.cornerRadius = handleSize.height / 2
+ }
+
+ if closeAction != nil {
+ let closeButton = UIButton()
+ self.closeButton = closeButton
+ closeButton.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(closeButton)
+ let closeImage = UIImage(systemName: "xmark.circle.fill")
+ closeButton.setImage(closeImage, for: .normal)
+ closeButton.tintColor = .lightGray
+ closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
+ }
+
+ var layout = Layout()
+
+ // handle
+ if let handle = handle, let handleContainerView = handleContainerView {
+ layout.add(handleContainerView.alignTopAndSides(to: self))
+ layout.add(handleContainerView.constraintHeight(to: 44))
+
+ layout.add(handle.constraintWidth(to: handleSize.width))
+ layout.add(handle.constraintHeight(to: handleSize.height))
+ layout.add(handle.alignTopEdge(to: handleContainerView, insetBy: 5))
+ layout.add(handle.alignHorizontalCenter(to: handleContainerView))
+ }
+
+ // closeButton
+ if let closeButton = closeButton {
+ layout.add(closeButton.alignRightEdge(to: self, insetBy: 0))
+ layout.add(closeButton.alignTopEdge(to: self, insetBy: 0))
+ layout.add(closeButton.constraintWidth(to: 40))
+ layout.add(closeButton.constraintHeight(to: 40))
+ }
+
+ layout.activate()
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+open class ResizableBottomDrawer: BottomDrawer>, UIGestureRecognizerDelegate {
+ private var panGestureRecognizer: UIPanGestureRecognizer!
+
+ public typealias ContentView = V
+
+ public init(
+ _ cardContentView: ContentView,
+ showHandle: Bool = true,
+ closeAction: (() -> Void)? = nil,
+ styleFunc: @escaping (Styling.Traits) -> Styling.FloatingCard
+ ) {
+ super.init(
+ ResizableView(cardContentView, fixedEdge: .bottom),
+ showHandle: showHandle,
+ closeAction: closeAction,
+ styleFunc: styleFunc
+ )
+
+ let panGestureRecognizer = UIPanGestureRecognizer(
+ target: self,
+ action: #selector(handleDrag(_:))
+ )
+ panGestureRecognizer.delegate = self
+ addGestureRecognizer(panGestureRecognizer)
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ @IBAction func handleDrag(_ gr: UIPanGestureRecognizer) {
+ guard let superview = superview else { return }
+ contentView.handleDrag(gr, in: superview)
+ }
+
+ // MARK: - UIGestureRecognizerDelegate
+
+ public func gestureRecognizer(
+ _ gestureRecognizer: UIGestureRecognizer,
+ shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer)
+ -> Bool
+ {
+ return gestureRecognizer == panGestureRecognizer
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Views/CircleView.swift b/Sources/UIKitEx/Views/CircleView.swift
new file mode 100644
index 0000000..a39eacf
--- /dev/null
+++ b/Sources/UIKitEx/Views/CircleView.swift
@@ -0,0 +1,29 @@
+//
+// CircleView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 8/29/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public class CircleView: UIView {
+ public override init(frame: CGRect) {
+ super.init(frame: frame)
+ clipsToBounds = true
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ public override func layoutSubviews() {
+ super.layoutSubviews()
+ layer.cornerRadius = min(frame.width, frame.height) / 2.0
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Views/PassthroughView.swift b/Sources/UIKitEx/Views/PassthroughView.swift
new file mode 100644
index 0000000..2f37f39
--- /dev/null
+++ b/Sources/UIKitEx/Views/PassthroughView.swift
@@ -0,0 +1,20 @@
+//
+// PassthroughView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 10/24/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public class PassthroughView: UIView {
+ public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
+ let view = super.hitTest(point, with: event)
+ return view == self ? nil : view
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Views/ResizableView.swift b/Sources/UIKitEx/Views/ResizableView.swift
new file mode 100644
index 0000000..d24c09c
--- /dev/null
+++ b/Sources/UIKitEx/Views/ResizableView.swift
@@ -0,0 +1,247 @@
+//
+// ResizableView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 11/7/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public protocol ResizableContentView: UIView {
+ var preferredSortedHeights: [CGFloat] { get }
+ var canScrollDown: Bool { get }
+ var canScrollUp: Bool { get }
+}
+
+public protocol AnyResiableView: UIView {
+ func minimize()
+ func maximize()
+}
+
+open class ResizableView: UIView, AnyResiableView, ContainerView, UIGestureRecognizerDelegate {
+ public enum FixedEdge {
+ case top, bottom
+ }
+
+ public private(set) var fixedEdge: FixedEdge
+
+ public private(set) var contentView: V
+ private var height: NSLayoutConstraint!
+ private var heightBeforeDrag: CGFloat = 0 // height.constant before drag
+ private var preferredSortedHeights: [CGFloat] = [0]
+
+ private let minThresholdVelocity: CGFloat = 500
+
+ // location in superview
+ private var dragStartLocation: CGPoint = .zero
+
+ private var panGestureRecognizer: UIPanGestureRecognizer!
+
+ func updatePreferredSortedHeights() {
+ preferredSortedHeights = contentView.preferredSortedHeights
+ guard !preferredSortedHeights.isEmpty else {
+ assertionFailure()
+ return
+ }
+
+ func changeHeight(to newHeight: CGFloat) {
+ height.constant = newHeight
+ layoutContainerView()?.layoutIfNeeded()
+ }
+
+ func replaceWithActualHeight(index: Int) {
+ changeHeight(to: preferredSortedHeights[index])
+ preferredSortedHeights[index] = frame.height
+ }
+
+ layoutContainerView()?.setNeedsLayout()
+ layoutContainerView()?.layoutIfNeeded()
+ let currentHeight = frame.height
+
+ let firstIndex = 0
+ replaceWithActualHeight(index: firstIndex)
+ let firstHeight = preferredSortedHeights[firstIndex]
+ preferredSortedHeights.removeAll(where: { $0 < firstHeight })
+
+ let lastIndex = preferredSortedHeights.count - 1
+ replaceWithActualHeight(index: lastIndex)
+ let lastHeight = preferredSortedHeights[lastIndex]
+ preferredSortedHeights.removeAll(where: { $0 > lastHeight })
+
+ changeHeight(to: currentHeight)
+ }
+
+ private func closestPreferredHeight(to height: CGFloat) -> CGFloat {
+ var closestHeight = preferredSortedHeights[0]
+ for indexHeight in preferredSortedHeights.dropFirst() {
+ if abs(indexHeight - height) < abs(closestHeight - height) {
+ closestHeight = indexHeight
+ }
+ }
+ return closestHeight
+ }
+
+ private func closestPreferredHeight(to height: CGFloat, velocity: CGFloat) -> CGFloat {
+ guard velocity != 0 else { return height }
+ switch (fixedEdge, velocity > 0) {
+ case (.bottom, true), (.top, false):
+ return preferredSortedHeights.reversed().first { $0 <= height } ?? height
+ case (.bottom, false), (.top, true):
+ return preferredSortedHeights.first { $0 >= height } ?? height
+ }
+ }
+
+ private func layoutContainerView() -> UIView? {
+ guard let superview = superview else { return nil }
+ var res: UIView?
+ for view in sequence(first: superview, next: { $0.superview }) where view is ContainerView {
+ res = view
+ }
+ return res?.superview ?? res ?? superview
+ }
+
+ func resize(to newHeight: CGFloat) {
+ UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: []) {
+ self.height.constant = newHeight
+ self.heightBeforeDrag = newHeight
+ self.layoutContainerView()?.layoutIfNeeded()
+ }
+ }
+
+ public func minimize() {
+ guard let minHeight = preferredSortedHeights.first else {
+ assertionFailure()
+ return
+ }
+ resize(to: minHeight)
+ }
+
+ public func maximize() {
+ guard let maxHeight = preferredSortedHeights.last else {
+ assertionFailure()
+ return
+ }
+ resize(to: maxHeight)
+ }
+
+ @IBAction func handleDrag(_ gr: UIPanGestureRecognizer) {
+ guard let superview = superview else { return }
+ handleDrag(gr, in: superview)
+ }
+
+ func handleDrag(_ gr: UIPanGestureRecognizer, in view: UIView) {
+ let point = gr.translation(in: view)
+ let velocity = gr.velocity(in: view).y
+
+ // print("state: \(gr.state.rawValue), velocity: \(velocity)")
+
+ switch gr.state {
+ case .began:
+ heightBeforeDrag = frame.height
+ dragStartLocation = point
+
+ case .possible, .changed, .ended:
+ let dy: CGFloat
+ switch fixedEdge {
+ case .top:
+ dy = point.y - dragStartLocation.y
+ case .bottom:
+ dy = dragStartLocation.y - point.y
+ }
+
+ let minHeight = preferredSortedHeights.first ?? 0
+ let maxHeight = preferredSortedHeights.last ?? 0
+
+ var newHeight = heightBeforeDrag + dy
+ newHeight = max(minHeight, newHeight)
+ newHeight = min(newHeight, maxHeight)
+
+ if (gr.state == .ended) && (abs(velocity) > minThresholdVelocity) {
+ resize(to: closestPreferredHeight(to: newHeight, velocity: velocity))
+ }
+ else {
+ height.constant = newHeight
+ if case .ended = gr.state {
+ resize(to: closestPreferredHeight(to: newHeight))
+ }
+ }
+
+ case .cancelled, .failed:
+ height.constant = heightBeforeDrag
+
+ @unknown default:
+ break
+ }
+ }
+
+ public init(_ contentView: V, fixedEdge: FixedEdge) {
+ self.contentView = contentView
+ self.fixedEdge = fixedEdge
+
+ super.init(frame: .zero)
+
+ contentView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(contentView)
+
+ var layout = Layout()
+ height = layout.add(constraintHeight(to: frame.size.height, priority: .almostRequired))
+ layout.add(contentView.align(to: self))
+ layout.activate()
+
+ panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handleDrag(_:)))
+ panGestureRecognizer.delegate = self
+ addGestureRecognizer(panGestureRecognizer)
+
+ preferredSortedHeights = contentView.preferredSortedHeights
+ minimize()
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - UIGestureRecognizerDelegate
+
+ public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool
+ {
+ assert(gestureRecognizer === panGestureRecognizer)
+
+ let velocity = panGestureRecognizer.velocity(in: superview).y
+ guard velocity != 0 else { return false }
+
+ let canScrollContentView: Bool
+ switch fixedEdge {
+ case .top:
+ canScrollContentView = contentView.canScrollDown
+ case .bottom:
+ canScrollContentView = contentView.canScrollUp
+ }
+
+ updatePreferredSortedHeights()
+ let minHeight = preferredSortedHeights.first ?? 0
+ let maxHeight = preferredSortedHeights.last ?? 0
+
+ switch (fixedEdge, velocity > 0) {
+ case (.bottom, true), (.top, false):
+ return (heightBeforeDrag > minHeight) && !canScrollContentView
+ case (.bottom, false), (.top, true):
+ return (heightBeforeDrag < maxHeight ) && !canScrollContentView
+ }
+ }
+
+ public func gestureRecognizer(
+ _ gestureRecognizer: UIGestureRecognizer,
+ shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer)
+ -> Bool
+ {
+ guard gestureRecognizer == panGestureRecognizer else { return false }
+ guard let scrollView = otherGestureRecognizer.view as? UIScrollView else { return false }
+ guard scrollView.isDescendant(of: self) else { return false }
+ return true
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Views/SelfSizingCollectionView.swift b/Sources/UIKitEx/Views/SelfSizingCollectionView.swift
new file mode 100644
index 0000000..4d720db
--- /dev/null
+++ b/Sources/UIKitEx/Views/SelfSizingCollectionView.swift
@@ -0,0 +1,33 @@
+//
+// SelfSizingCollectionView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 8/29/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+open class SelfSizingCollectionView: UICollectionView {
+ private var height: NSLayoutConstraint?
+
+ private func updateHeight() {
+ let contentHeight = max(contentSize.height, 1)
+ if let height = height {
+ height.constant = contentHeight
+ }
+ else {
+ height = constraintHeight(to: contentHeight, priority: .almostRequired).activate()
+ }
+ }
+
+ public override var contentSize: CGSize {
+ didSet {
+ updateHeight()
+ }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Views/SelfSizingImageView.swift b/Sources/UIKitEx/Views/SelfSizingImageView.swift
new file mode 100644
index 0000000..937dbd1
--- /dev/null
+++ b/Sources/UIKitEx/Views/SelfSizingImageView.swift
@@ -0,0 +1,34 @@
+//
+// SelfSizingImageView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 9/25/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+open class SelfSizingImageView: UIImageView {
+ private var aspectRatio: NSLayoutConstraint?
+
+ private func updateAspectRatio(image: UIImage) {
+ let value = image.size.aspectRatio
+ if let aspectRatio = aspectRatio {
+ aspectRatio.constant = value
+ }
+ else {
+ aspectRatio = self.constraintAspectRatio(to: value, priority: .almostRequired).activate()
+ }
+ }
+
+ public override var image: UIImage? {
+ didSet {
+ guard let image = image else { return }
+ updateAspectRatio(image: image)
+ }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Views/SelfSizingWebView.swift b/Sources/UIKitEx/Views/SelfSizingWebView.swift
new file mode 100644
index 0000000..cfce00b
--- /dev/null
+++ b/Sources/UIKitEx/Views/SelfSizingWebView.swift
@@ -0,0 +1,81 @@
+//
+// SelfSizingWebView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 9/23/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+import Combine
+import CombineEx
+import WebKit
+
+open class SelfSizingWebView: WKWebView {
+ private var subscriptions = Set()
+
+ private var height: NSLayoutConstraint?
+ private var _finishedLoading = PassthroughSubject()
+ private var updatedHeight = PassthroughSubject()
+
+ public var openLink: (URL) -> Void = { _ in }
+
+ public override init(frame: CGRect, configuration: WKWebViewConfiguration) {
+ super.init(frame: frame, configuration: configuration)
+ navigationDelegate = self
+ self.publisher(for: \.scrollView.contentSize).removeDuplicates().sink { [unowned self] _ in
+ assert(Thread.isMainThread)
+ self.frame.size.height = 1
+ self.evaluateJavaScript("document.body.scrollHeight") { (value, _) in
+ let heightJS = (value as? CGFloat) ?? 0
+ if let height = self.height {
+ height.constant = heightJS
+ }
+ else {
+ self.height = self.constraintHeight(to: heightJS).activate()
+ }
+ self.updatedHeight.send(())
+ }
+ }
+ .store(in: &subscriptions)
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ public func loadHTML(_ html: String) {
+ loadHTMLString(html, baseURL: Bundle.main.resourceURL)
+ }
+
+ public var finishedLoading: AnyPublisher {
+ _finishedLoading
+ .combineLatest(self.updatedHeight)
+ .debounce(for: .seconds(0.1), scheduler: DispatchQueue.main)
+ .map { _ in () }
+ .eraseToAnyPublisher()
+ }
+}
+
+extension SelfSizingWebView: WKNavigationDelegate {
+ public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ _finishedLoading.send(())
+ }
+
+ public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+ switch navigationAction.navigationType {
+ case .linkActivated:
+ if let url = navigationAction.request.url {
+ openLink(url)
+ }
+ decisionHandler(.cancel)
+
+ default:
+ decisionHandler(.allow)
+ }
+ }
+}
+
+#endif
diff --git a/Sources/UIKitEx/Views/WidthSizingImageView.swift b/Sources/UIKitEx/Views/WidthSizingImageView.swift
new file mode 100644
index 0000000..5d3319f
--- /dev/null
+++ b/Sources/UIKitEx/Views/WidthSizingImageView.swift
@@ -0,0 +1,31 @@
+//
+// WidthSizingImageView.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 1/16/21.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+open class WidthSizingImageView: UIImageView {
+ private var lastWidth: CGFloat = 0
+
+ public override var intrinsicContentSize: CGSize {
+ guard let image = image else { return super.intrinsicContentSize }
+ let width = bounds.width
+ let height: CGFloat = width / image.size.aspectRatio
+ return CGSize(width: width, height: height)
+ }
+
+ public override func layoutSubviews() {
+ super.layoutSubviews()
+ guard bounds.width != lastWidth else { return }
+ lastWidth = bounds.width
+ invalidateIntrinsicContentSize()
+ }
+}
+
+#endif
diff --git a/Tests/UIKitExTests/ColorStylingTests.swift b/Tests/UIKitExTests/ColorStylingTests.swift
new file mode 100644
index 0000000..77188ff
--- /dev/null
+++ b/Tests/UIKitExTests/ColorStylingTests.swift
@@ -0,0 +1,65 @@
+//
+// ColorStylingTests.swift
+// Rocket Insights
+//
+// Created by Ilya Belenkiy on 6/19/20.
+// Copyright © 2021 Rocket Insights. All rights reserved.
+//
+
+
+import XCTest
+import UIKitEx
+
+class ColorStylingTests: XCTestCase {
+ func testHexBlack() throws {
+ let color = try Styling.Color(hex: "#000000FF")
+ XCTAssertEqual(color.red, 0)
+ XCTAssertEqual(color.green, 0)
+ XCTAssertEqual(color.blue, 0)
+ XCTAssertEqual(color.opacity, 1)
+ }
+
+ func testHexRed() throws {
+ let color = try Styling.Color(hex: "#FF0000FF")
+ XCTAssertEqual(color.red, 1)
+ XCTAssertEqual(color.green, 0)
+ XCTAssertEqual(color.blue, 0)
+ XCTAssertEqual(color.opacity, 1)
+ }
+
+ func testHexGreen() throws {
+ let color = try Styling.Color(hex: "#00FF00FF")
+ XCTAssertEqual(color.red, 0)
+ XCTAssertEqual(color.green, 1)
+ XCTAssertEqual(color.blue, 0)
+ XCTAssertEqual(color.opacity, 1)
+ }
+
+ func testHexBlue() throws {
+ let color = try Styling.Color(hex: "#0000FFFF")
+ XCTAssertEqual(color.red, 0)
+ XCTAssertEqual(color.green, 0)
+ XCTAssertEqual(color.blue, 1)
+ XCTAssertEqual(color.opacity, 1)
+ }
+
+ func testMixed() throws {
+ let color = try Styling.Color(hex: "C0D6E4C3")
+ XCTAssertEqual(color.red, 192.0 / 255.0)
+ XCTAssertEqual(color.green, 214.0 / 255.0)
+ XCTAssertEqual(color.blue, 228.0 / 255.0)
+ XCTAssertEqual(color.opacity, 195.0 / 255.0)
+ }
+
+ func testOpacity() throws {
+ let color = try Styling.Color(hex: "C0D6E4", opacity: 195.0 / 255.0)
+ XCTAssertEqual(color.red, 192.0 / 255.0)
+ XCTAssertEqual(color.green, 214.0 / 255.0)
+ XCTAssertEqual(color.blue, 228.0 / 255.0)
+ XCTAssertEqual(color.opacity, 195.0 / 255.0)
+ }
+
+ func testAsLiteral() {
+ let _: Styling.Color = "#C0D6E4C3"
+ }
+}