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("") + } + + 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" + } +}