diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a1dadbd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: + - main + paths-ignore: + - 'README.md' + - 'CODE_OF_CONDUCT.md' + - 'CONTRIBUTING.md' + - 'LICENSE' + - 'SECURITY.md' + - 'ios.yml' + pull_request: + branches: + - main + +jobs: + test: + name: Test + runs-on: macOS-latest + strategy: + matrix: + destination: + - "platform=macOS" + # - "platform=macOS,variant=Mac Catalyst" + - "platform=iOS Simulator,name=iPhone 11" + + steps: + - uses: actions/checkout@v4 + - name: Install XCBeautify + run: brew install xcbeautify + - name: Show buildable schemes + run: xcodebuild -list + - name: Test Each Platform + run: set -o pipefail && xcodebuild -scheme PillboxView -destination "${{ matrix.destination }}" test | xcbeautify --renderer github-actions diff --git a/.gitignore b/.gitignore index 18d6b11..53adc0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ - -*.xcuserstate +## User settings +xcuserdata/ +**/.swiftpm +**/.DS_Store *.xcuserstate diff --git a/Package.swift b/Package.swift index b107b3e..ea79315 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,10 @@ import PackageDescription let package = Package( name: "PillboxView", platforms: [ - .iOS(.v13), .macCatalyst(.v13), .tvOS(.v13) + .iOS(.v13), + .macCatalyst(.v13), + .tvOS(.v13), + .macOS(.v11) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. @@ -15,15 +18,17 @@ let package = Package( targets: ["PillboxView"]), ], dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), + // Add NSUI to support macOS compatibility + .package(url: "https://github.com/mattmassicotte/nsui", branch: "main"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "PillboxView", - dependencies: [], + dependencies: [ + .product(name: "NSUI", package: "nsui") + ], path: "Sources"), .testTarget( name: "PillboxViewTests", diff --git a/README.md b/README.md index 93e951b..8e009d5 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@ PillboxView is a small pill that presents a view on an asynchronous on-going tas PillboxView is available through [Swift Package Manager](https://www.swift.org/package-manager). +## Project Dependency +In order to support both native `AppKit` and `UIKit`, PillboxView is leveraging the `NSUI` project. +[NSUI](https://github.com/mattmassicotte/nsui) allows a single codebase to support both platforms with less #if pragma statements + +The package description file `package.swift` defines that dependency. You should be aware of that information before including +`PillboxView` into your own project + ## Example - Display a title message @@ -41,7 +48,10 @@ class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - pill.show(title: "Refreshing Data", vcView: self.view) + pill.showTask(message: "Refreshing Data", vcView: self.view) + + // Update the task message while the task is ongoing + pill.updateTask(message: "Still refreshing data...") // some time later... DispatchQueue.main.asyncAfter(deadline: .now() + 2) { diff --git a/Sources/.DS_Store b/Sources/.DS_Store deleted file mode 100644 index ec696f0..0000000 Binary files a/Sources/.DS_Store and /dev/null differ diff --git a/Sources/PillboxView/Conformance.swift b/Sources/PillboxView/Conformance.swift index 204de3e..9d72833 100644 --- a/Sources/PillboxView/Conformance.swift +++ b/Sources/PillboxView/Conformance.swift @@ -7,6 +7,9 @@ import Foundation +/* + // Deriving PillView from NSUIView (aka UIView) provides Hashable conformance automatically + // The code below in unneeded extension PillView: Hashable { public static func == (lhs: PillView, rhs: PillView) -> Bool { lhs.pillView == rhs.pillView @@ -34,3 +37,4 @@ extension PillView: Hashable { hasher.combine(vcView) } } +*/ diff --git a/Sources/PillboxView/NSUI+Extensions.swift b/Sources/PillboxView/NSUI+Extensions.swift new file mode 100644 index 0000000..a9a7fec --- /dev/null +++ b/Sources/PillboxView/NSUI+Extensions.swift @@ -0,0 +1,48 @@ +// +// NSUI+Extensions.swift +// +// + +import CoreGraphics +import Foundation + +// --- +import NSUI + +internal extension NSUIView { + + /// Return the origin `UXPoint` for a view which needs to be centered horizontally within its superview + /// This is performed with frame math only and it is set at init type. + /// Changing the window size will not recalculate the origin. + func originForCenter(inRelationTo parentView: NSUIView) -> CGPoint { + guard + parentView.frame != CGRect.zero + else { + fatalError("Your parentView must have a non-zero size") + } + + let midPoint = CGRectGetMidX(parentView.frame) + + // Now get the half the width of our view and substract than from the midPoint + let selfMidPoint = self.frame.width / 2 + + let newOriginX = (midPoint - selfMidPoint).rounded() + let newOriginY = self.frame.origin.y + return CGPoint(x: newOriginX, y: newOriginY) + } +} + +#if canImport(AppKit) +import AppKit +#endif + +internal extension NSUIColor { + #if os(macOS) + @available(OSX 10.14, *) + static var isLight: Bool { NSApp.effectiveAppearance.name == NSAppearance.Name.aqua } + + @available(OSX 10.14, *) + static var isDark: Bool { NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua } + #endif +} + diff --git a/Sources/PillboxView/PillAnimation.swift b/Sources/PillboxView/PillAnimation.swift new file mode 100644 index 0000000..f99c9e5 --- /dev/null +++ b/Sources/PillboxView/PillAnimation.swift @@ -0,0 +1,23 @@ +// +// PillPosition.swift +// +// +// Created by Martin Dufort on 2023-12-14. +// + + +/// Defines the direction from which the ``PillboxView/PillView`` will appear and also the offset from the edge of +/// the containing view to where it will rest. This allows stop coordinates to be different if showing from bottom versus +/// showing from top. +/// +/// It also removes the need to inform the ``PillboxView/PillView`` about the presence of a navigation controller. +import CoreGraphics + +public struct PillAnimation { + enum AnimationDirection { + case fromTop + case fromBottom + } + var direction: AnimationDirection + var offsetFromEdge: CGFloat +} diff --git a/Sources/PillboxView/PillColors.swift b/Sources/PillboxView/PillColors.swift index 9137a2a..ea4e75b 100644 --- a/Sources/PillboxView/PillColors.swift +++ b/Sources/PillboxView/PillColors.swift @@ -3,26 +3,54 @@ // PillboxView // // Created by Jacob Trentini on 1/2/22. -// -import UIKit +import NSUI +#if canImport(AppKit) +import AppKit +#endif -internal extension UIColor { - static var PillboxBackgroundColor: UIColor { - return UIColor { (traits) -> UIColor in +internal extension NSUIColor { + #if os(macOS) + @available(OSX 10.14, *) + static var isLightModeOn: Bool { NSApp.effectiveAppearance.name == NSAppearance.Name.aqua } + + @available(OSX 10.14, *) + static var isDarkModeOn: Bool { NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua } + #endif + + static var PillboxBackgroundColor: NSUIColor { + #if os(macOS) + if NSUIColor.isLightModeOn { + return NSUIColor.white + } + else { + return NSUIColor.lightGray + } + #else + return NSUIColor { (traits) -> NSUIColor in // Return one of two colors depending on light or dark mode #if targetEnvironment(macCatalyst) return traits.userInterfaceStyle == .light ? - .white : UIColor(red: 0.09, green: 0.09, blue: 0.09, alpha: 1) + .white : NSUIColor(red: 0.09, green: 0.09, blue: 0.09, alpha: 1) #else return traits.userInterfaceStyle == .light ? - .white : UIColor(red: 0.12941176, green: 0.12156863, blue: 0.10588235, alpha: 1) + .white : NSUIColor(red: 0.12941176, green: 0.12156863, blue: 0.10588235, alpha: 1) #endif } + #endif } - static var PillboxTitleColor: UIColor { - return UIColor(displayP3Red: 0.54117647, green: 0.5372549, blue: 0.55294118, alpha: 1) + static var PillboxTitleColor: NSUIColor { + #if os(macOS) + if NSUIColor.isLightModeOn { + return NSUIColor.darkGray + } + else { + return NSUIColor.white + } + #else + return NSUIColor(displayP3Red: 0.54117647, green: 0.5372549, blue: 0.55294118, alpha: 1) + #endif } } diff --git a/Sources/PillboxView/PillView.swift b/Sources/PillboxView/PillView.swift index 98fcb72..c089e6e 100644 --- a/Sources/PillboxView/PillView.swift +++ b/Sources/PillboxView/PillView.swift @@ -3,36 +3,54 @@ // PillboxView // // Created by Jacob Trentini on 12/30/21. -// +// macOS Compatibility (via NSUI) added by Martin Dufort on 12/12/23 + +import CoreGraphics +import Foundation +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) import UIKit +#endif -/// A `UIView` to display two forms of dynamic content based on the conditions or needs of a developer. -public class PillView { - - /// The `UIView` itself that holds the content of the ``PillboxView/PillView``, such as a title and imageView. +// --- +import NSUI // To unify AppKit and UIKit and add macOS compatibility + + +/// A `NSUIView` to display two forms of dynamic content based on the conditions or needs of a developer. +public class PillView: NSUIView { + /// The `NSUIView` itself that holds the content of the ``PillboxView/PillView``, such as a title and imageView. /// /// This shows information based on the ``PillboxView/PillShowType``. - /// Both causes hold a title/message `UILabel` and a `UIImageView`, customizable to the developer's suitable needs. - public var pillView = UIView() + /// Both causes hold a title/message `NSUILabel` and a `NSUIImageView`, customizable to the developer's suitable needs. + // ?? Do we need to define another UXView here. This implementation is deriving from UXView ?? + // public var pillView = UXView() + /// The width of the ``PillboxView/PillView/pillView``. - public var width = 200 + private static let defaultWidth: CGFloat = 400 + public var width: CGFloat { + return self.frame.width + } /// The height of the ``PillboxView/PillView/pillView``. /// /// If a `UINavigationController` obstructs it, then set ``PillboxView/PillView/isNavigationControllerPresent`` to `true` - public var height = 45 + private static let defaultHeight: CGFloat = 200 + public var height: CGFloat { + return self.frame.height + } - /// A `UIActivityIndicatorView` for the asynchronous task of the ``PillboxView/PillShowType/ongoingTask``. + /// A `NSSpinner` aka `NSUIActivityIndicatorView` in UIKIt for the asynchronous task of the ``PillboxView/PillShowType/ongoingTask``. /// /// This should not be used if using ``PillboxView/PillShowType/error`` - public private(set) var activityIndicator = UIActivityIndicatorView() + public private(set) var activityIndicator = NSUIActivityIndicatorView() - /// A `UILabel` align on the ``PillboxView/PillView/pillView``'s center-left to display a message. + /// A `NSUILabel` align on the ``PillboxView/PillView/pillView``'s center-left to display a message. /// /// The message should not be set through accessing the properties of this label, but rather ``PillboxView/PillView/completedTask(state:completionHandler:)`` or ``PillboxView/PillView/showError(message:vcView:)``. - public private(set) var titleLabel = UILabel() + public private(set) var titleLabel = NSUILabel() /// A Boolean value indicating whether the current ``PillboxView/PillView`` is waiting for a task to complete. /// @@ -46,38 +64,42 @@ public class PillView { /// /// Note: This will only be used for the ``PillboxView/PillView/showType`` = ``PillboxView/PillShowType/ongoingTask``. /// Make sure that the symbol forms an even aspect ration of 30 by 30 for the best quality. - public var successSymbol = UIImage(systemName: "checkmark.circle")! + /// + public var successSymbol = NSUIImage(systemName: "checkmark.circle") /// Shows the failure symbol that should be used. /// /// Note: This will only be used for the ``PillboxView/PillView/showType`` = ``PillboxView/PillShowType/ongoingTask``. /// Make sure that the symbol forms an even aspect ration of 30 by 30 for the best quality. - public var failureSymbol = UIImage(systemName: "x.circle")! - + public var failureSymbol = NSUIImage(systemName: "x.circle")! /// Shows the error symbol that should be used. /// /// Note: This will only be used for the ``PillboxView/PillView/showType`` = ``PillboxView/PillShowType/error``. /// Make sure that the symbol forms an even aspect ration of 30 by 30 for the best quality. - public var errorSymbol = UIImage(systemName: "wifi.exclamationmark")! - - /// The desired `UIView` that you would like the ``PillboxView/PillView/pillView`` displayed on. - /// - /// Most of the time, this will be your `ViewController.view`, since `view` is derived from the `UIStoryboard`. + public var errorSymbol = NSUIImage(systemName: "wifi.exclamationmark")! + + /// The parent `NSUIView` that you would like the ``PillboxView/PillView/pillView`` displayed on. /// - /// Note: ``PillboxView/PillView`` does not need to be placed on a `UIViewController`, but could be placed on any such `UIView`. - public private(set) var vcView: UIView? + /// Note: ``PillboxView/PillView`` does not need to be placed on a `NSUIViewController`, but could be placed on any such `NSUIView`. + public private(set) weak var vcView: NSUIView! /// This helps developers determine which type the ``PillboxView/PillShowType``. /// /// This is set automatically, and cannot be changed. This could come handy when you would want to filter out a specific case from the ``PillboxView/PillView/activePillBoxViews``. public private(set) var showType: PillShowType? = nil + #if os(iOS) /// A Boolean value to allowing ``PillboxView/PillView`` to work around having a `UINavigationController` at the top of the screen. /// /// The `UINavigationController` can block the top of the screen, thus obstructing the ``PillboxView/PillView/pillView`` - /// Set this to true to let the ``PillboxView/PillView/pillView`` ``PillboxView/PillView/reveal(animated:completionHandler:)`` 40 pixels higher (y-axis, lower down on the screen from the top). + /// Set this to true to let the ``PillboxView/PillView/pillView`` ``PillboxView/PillView/reveal(animated:completionHandler:)`` 40 pixels higher (y-axis, lower down on the screen from the top in UIKit world). public var isNavigationControllerPresent = Bool() + #endif + + /// The font to be used for displaying ``PillboxView/PillView`` messages on the screen. + /// By default, the font is nil and defaults to the normal font. + public private(set) var font: NSUIFont? = nil /// The `Set` holds unique ``PillboxView/PillView`` shown on the screen at the given time. /// @@ -89,8 +111,27 @@ public class PillView { /// The basic initialization of ``PillboxView/PillView``, which includes all of the default parameters. /// /// Use the other initializers to set fields/values of the ``PillboxView/PillView``. While you could modify some of the fields/properties with default values, some of them cannot be mutated. - /// Note that ``PillboxView/PillView`` does not rely on this value, and is supposed to be for the developer's benefit/knowledge. - public init() { } + public init() { + super.init(frame: CGRect(origin: CGPoint.zero, size: CGSize(width: Self.defaultWidth, height: Self.defaultHeight))) + self._internalInit() + } + + public override init(frame frameRect: CGRect) { + super.init(frame: frameRect) + self._internalInit() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + self._internalInit() + } + + private func _internalInit() { + #if os(macOS) + self.wantsLayer = true + #endif + // self.frame.size = CGSize(width: self.width, height: self.height) + } /// This sets the ``PillboxView/PillView/showType`` ahead of when the computer will automatically set the value of this. /// @@ -101,34 +142,57 @@ public class PillView { /// Note that ``PillboxView/PillView`` does not rely on this value, and is supposed to be for the developer's benefit/knowledge. /// /// - Parameter showType: This helps developers determine which type the ``PillboxView/PillShowType``. - public init(showType: PillShowType) { + /// - Parameter font: This specifies the font to be user to display ``PillboxView/PillView`` messages + /// Default is nil which will used the default font for the platform + /// - Parameter isNavigationControllerPresent: A Boolean value to allowing ``PillboxView/PillView`` to work around having a `UINavigationController` at the top of the screen. + /// The default value of this is false. + #if os(macOS) + public convenience init(showType: PillShowType? = nil, font: NSUIFont? = nil) { + self.init() + self.showType = showType + self.font = font + } + #else + public convenience init(showType: PillShowType? = nil, font: NSUIFont? = nil, isNavigationControllerPresent: Bool = false) { + self.init() self.showType = showType + self.font = font + + self.isNavigationControllerPresent = isNavigationControllerPresent } + #endif + #if os(iOS) /// Initialize this value overriding the ``PillboxView/PillView/isNavigationControllerPresent`` value - /// - Parameter isNavigationControllerPresent: A Boolean value to allowing ``PillboxView/PillView`` to work around having a `UINavigationController` at the top of the screen. - /// - /// The default value of this is false. - public init(isNavigationControllerPresent: Bool) { + public convenience init(isNavigationControllerPresent: Bool) { + self.init() self.isNavigationControllerPresent = isNavigationControllerPresent } + #endif + /// Initializes with different values than the default width and height values /// /// - Parameters: /// - width: The width of the ``PillboxView/PillView/pillView``. /// - height: The height of the ``PillboxView/PillView/pillView``. - public init(width: Int, height: Int) { + public convenience init(width: Int, height: Int) { + self.init(frame: CGRect(x: 0, y: 0, width: width, height: height)) + + /* self.width = width self.height = height + */ self.showType = nil } - /// This allows developers to use their own `UIActivityIndicator` instead of the default. + /// This allows developers to use their own `ActivityIndicator` instead of the default. /// /// This can open a wide range of possibilities, including style, color, and animation preferences. - /// - Parameter activityIndicator: A `UIActivityIndicatorView` for the asynchronous task of the ``PillboxView/PillShowType/ongoingTask``. - public init(activityIndicator: UIActivityIndicatorView) { + /// - Parameter activityIndicator: A `NSUIActivityIndicatorView` for the asynchronous task of the ``PillboxView/PillShowType/ongoingTask``. + public convenience init(activityIndicator: NSUIActivityIndicatorView) { + self.init() + self.activityIndicator = activityIndicator self.showType = nil } @@ -145,37 +209,57 @@ public class PillView { /// /// - Parameters: /// - state: A Boolean value indicating whether the asynchronous task the ``PillboxView/PillView`` has been waiting on has been successful (true) or unsuccessful (false). + /// - message: An updated message to be displayed when completing a task. + /// - timeBeforeMoveOut: Time in secs to wait before moving the pill out of sight /// - completionHandler: A completion handler indicating when the animation has finished. - open func completedTask(state: Bool, completionHandler: (() -> Void)? = nil) { + open func completedTask(state: Bool, message: String? = nil, timeBeforeMoveOut: TimeInterval = 1.5, completionHandler: (() -> Void)? = nil) { // PillView.activePillBoxViews.remove(self) - DispatchQueue.main.async { [self] in + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } guard - let titleLabel = pillView.viewWithTag(1) as? UILabel, - let imageView = pillView.viewWithTag(2) as? UIImageView + let titleLabel = self.viewWithTag(1) as? NSUILabel, + let imageView = self.viewWithTag(2) as? NSUIImageView else { return } - imageView.image = state ? successSymbol : failureSymbol - imageView.tintColor = state ? .systemGreen : .systemRed + // Display the new message upon completion is specified + if let message = message { + titleLabel.text = message + } + + imageView.image = state ? self.successSymbol : self.failureSymbol + imageView.tintColor = state ? NSUIColor.systemGreen : NSUIColor.systemRed imageView.isHidden = true + #if os(macOS) + let viewAnimationKeys: [[NSViewAnimation.Key: Any]] = + [[NSViewAnimation.Key.effect: NSViewAnimation.EffectName.fadeOut, + NSViewAnimation.Key.target: self.activityIndicator], + [NSViewAnimation.Key.effect: NSViewAnimation.EffectName.fadeIn, + NSViewAnimation.Key.target: imageView]] + + let viewAnimation = NSViewAnimation(viewAnimations: viewAnimationKeys) + viewAnimation.start() + #else UIView.transition(from: self.activityIndicator, to: imageView, duration: 0.25, options: .transitionCrossDissolve) { _ in self.activityIndicator.stopAnimating() self.activityIndicator.isHidden = true imageView.isHidden = false } + #endif - dismiss() - - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + // Dismiss the pill + self.dismiss(timeBeforeMoveOut: timeBeforeMoveOut) { + // And remove all elements from the view hierarchy imageView.removeFromSuperview() titleLabel.removeFromSuperview() + + self.removeFromSuperview() self.showType = nil if let completionHandler = completionHandler { completionHandler() } + self.isAwaitingTaskCompletion = false } - - isAwaitingTaskCompletion = false } } @@ -190,74 +274,99 @@ public class PillView { /// - message: The desired message the ``PillboxView/PillView/titleLabel`` should present. /// Make sure this message is short and concise; otherwise, it will hang off the ``PillboxView/PillView/pillView``, assuming the use of the default ``PillboxView/PillView/width`` value of `200` /// This ``PillboxView/PillView/titleLabel`` is left-center aligned, and the ``PillboxView/PillView/activityIndicator`` is right-center aligned. - /// - vcView: The desired `UIView` that you would like the ``PillboxView/PillView/pillView`` displayed on. - /// - tintColor: A tint color for the `UIImageView` of the ``PillboxView/PillView/pillView/`` displayed on. + /// - vcView: The desired `NSUIView` that you would like the ``PillboxView/PillView/pillView`` displayed on. + /// - tintColor: A tint color for the `NSUIImageView` of the ``PillboxView/PillView/pillView/`` displayed on. /// - completionHandler: A completion handler indicating when the animation has finished. - open func showTask(message: String, vcView: UIView, tintColor: UIColor = .systemBlue, completionHandler: (() -> Void)? = nil) { - + open func showTask(message: String, vcView: NSUIView, tintColor: NSUIColor = NSUIColor.systemBlue, completionHandler: (() -> Void)? = nil) { + // Now configure against the view hierarchy. Mostly centering the pill initial position + self.configurePill(parentView: vcView) + self.showType = .ongoingTask - - self.vcView = vcView -// PillView.activePillBoxViews.insert(self) - - // pillView init - pillView.frame = CGRect(x: Int(vcView.frame.midX), y: -300, width: width, height: height) - pillView.center.x = vcView.center.x - pillView.backgroundColor = UIColor.PillboxBackgroundColor - pillView.layer.cornerRadius = 20 - - // shadow for pillView - pillView.layer.shadowColor = UIColor.black.cgColor - pillView.layer.shadowOpacity = 0.1 - pillView.layer.shadowOffset = .zero - pillView.layer.shadowRadius = 10 - - // titleLabel - titleLabel = UILabel(frame: CGRect(x: 0, - y: 11, - width: pillView.frame.width - 40, - height: 23)) - titleLabel.text = message - titleLabel.textAlignment = .center - titleLabel.textColor = UIColor.PillboxTitleColor - titleLabel.tag = 1 - - // activityIndicator - activityIndicator = UIActivityIndicatorView(frame: CGRect(x: titleLabel.frame.maxX, - y: 11, - width: (pillView.frame.width - 15) - titleLabel.frame.maxX, - height: 23)) - activityIndicator.startAnimating() - - let imageView = UIImageView(frame: CGRect(x: titleLabel.frame.maxX, - y: 11, - width: (pillView.frame.width - 15) - titleLabel.frame.maxX, + + // titleLabel which should be centered within the superview + self.titleLabel = NSUILabel(frame: CGRect(x: 0, + y: 6, + width: self.frame.width - 40, height: 23)) + + self.titleLabel.text = message + self.titleLabel.textAlignment = .center + self.titleLabel.font = self.font + #if os(macOS) + self.titleLabel.isBordered = false + #endif + self.titleLabel.textColor = NSUIColor.PillboxTitleColor + self.titleLabel.backgroundColor = .clear + self.titleLabel.tag = 1 + self.centerVertically(subview: self.titleLabel, horizontalAlignment: .leading) + + // Setup activityIndicator + self.activityIndicator = NSUIActivityIndicatorView(frame: CGRect(x: self.titleLabel.frame.maxX, + y: 10, + width: 16, + height: 16)) + #if os(macOS) + self.activityIndicator.style = .spinning + self.activityIndicator.controlSize = .small + #endif + self.activityIndicator.startAnimating() + self.centerVertically(subview: self.activityIndicator, horizontalAlignment: .trailing) + + // Setup completed task imageView + let imageView = NSUIImageView(frame: CGRect(x: self.titleLabel.frame.maxX, + y: 10, + width: 16, + height: 16)) imageView.isHidden = true imageView.tintColor = tintColor imageView.tag = 2 - - // moving/adding into frame - - pillView.addSubview(titleLabel) - pillView.addSubview(activityIndicator) - pillView.addSubview(imageView) - - UIView.animate(withDuration: 1) { + self.centerVertically(subview: imageView, horizontalAlignment: .trailing) - self.pillView.frame = CGRect(x: Int(vcView.frame.midX), - y: UIDevice.current.hasNotch ? 45: 25 + (self.isNavigationControllerPresent ? 40 : 0), - width: self.width, height: self.height) + // Inserting into view hierarchy + self.addSubview(titleLabel) + self.addSubview(activityIndicator) + self.addSubview(imageView) + + #if os(macOS) + NSAnimationContext.runAnimationGroup{ context in + context.duration = 1.0 + context.allowsImplicitAnimation = true + context.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) + + // Need to calculate the origin of the pill view from the center point + let origin = self.originForCenter(inRelationTo: vcView) + let yPos = vcView.frame.height /* Offset below top edge (negative value) */ - 50.0 + self.frame.origin = CGPoint(x: origin.x, y: yPos) + } + #else + NSUIView.animate(withDuration: 1) { + self.frame = CGRect(x: vcView.frame.midX, + y: UIDevice.current.hasNotch ? 45: 25 + (self.isNavigationControllerPresent ? 40 : 0), + width: self.width, height: self.height) - self.pillView.center.x = vcView.center.x + self.center.x = vcView.center.x if let completionHandler = completionHandler { completionHandler() } } + +// vcView.addSubview(self.pillView) /* This is not performed in the configurePill(parentView:) function + #endif - vcView.addSubview(pillView) - - isAwaitingTaskCompletion = true + self.isAwaitingTaskCompletion = true + } + + /// + /// Update the task pill with a new message + public func updateTask(message: String) { + guard + self.isAwaitingTaskCompletion + else { + return + } + + // TODO: This new message should be animated to replace the previous one. + self.titleLabel.text = message } /// Starts the acknowledgement of the error of an instant task to the main UI. @@ -272,59 +381,69 @@ public class PillView { /// - message: The desired message the ``PillboxView/PillView/titleLabel`` should present. /// Make sure this message is short and concise; otherwise, it will hang off the ``PillboxView/PillView/pillView``, assuming the use of the default ``PillboxView/PillView/width`` value of `200` /// This ``PillboxView/PillView/titleLabel`` is left-center aligned, and the ``PillboxView/PillView/activityIndicator`` is right-center aligned. - /// - vcView: The desired `UIView` that you would like the ``PillboxView/PillView/pillView`` displayed on. + /// - vcView: The desired `NSUIView` that you would like the ``PillboxView/PillView/pillView`` displayed on. /// - tintColor: A tint color for the `UIImageView` of the ``PillboxView/PillView/pillView/`` displayed on. /// - timeToShow: Length of time to show in seconds/double (`TimeInterval`). Note that this should be at least `2` seconds and that this does not include the animation times. See source code for animation timings /// - completionHandler: A completion handler indicating when the animation has finished. - public func showError(message: String, vcView: UIView, tintColor: UIColor? = .systemRed, timeToShow: TimeInterval = 2, completionHandler: (() -> Void)? = nil) { - + public func showError(message: String, vcView: NSUIView, tintColor: NSUIColor? = .systemRed, timeToShow: TimeInterval = 2, completionHandler: (() -> Void)? = nil) { + self.configurePill(parentView: vcView) + let timeToShowErrorPill = timeToShow < 2 ? 2 : timeToShow - self.showType = .error - - // pillView init - pillView.frame = CGRect(x: 100, y: -300, width: width, height: height) - pillView.backgroundColor = UIColor.PillboxBackgroundColor - pillView.layer.cornerRadius = 20 - - // shadow for pillView - pillView.layer.shadowColor = UIColor.black.cgColor - pillView.layer.shadowOpacity = 0.1 - pillView.layer.shadowOffset = .zero - pillView.layer.shadowRadius = 10 - + // titleLabel - titleLabel = UILabel(frame: CGRect(x: 0, y: 11, width: pillView.frame.width - 40, height: 23)) - titleLabel.text = message - titleLabel.textAlignment = .center - titleLabel.textColor = UIColor.PillboxTitleColor - titleLabel.tag = 3 + self.titleLabel = NSUILabel(frame: CGRect(x: 0, y: 6, width: self.frame.width - 40, height: 23)) + self.titleLabel.text = message + self.titleLabel.textAlignment = .center + self.titleLabel.font = self.font + self.titleLabel.textColor = NSUIColor.PillboxTitleColor + self.titleLabel.backgroundColor = .clear + + #if os(macOS) + self.titleLabel.isBordered = false + #endif + self.titleLabel.tag = 3 // imageView - let imageView = UIImageView(frame: CGRect(x: titleLabel.frame.maxX, - y: 11, - width: (pillView.frame.width - 15) - titleLabel.frame.maxX, - height: 23)) + let imageView = NSUIImageView(frame: CGRect(x: titleLabel.frame.maxX, + y: 6, + width: (self.frame.width - 15) - titleLabel.frame.maxX, + height: 23)) - imageView.tag = 4 imageView.image = errorSymbol - imageView.tintColor = tintColor + imageView.tintColor = tintColor! + imageView.tag = 4 // moving/adding into frame + self.addSubview(titleLabel) + self.addSubview(imageView) + + #if os(macOS) + // Need to calculate the origin of the pill view from the center point + let originX = self.originForCenter(inRelationTo: self.vcView).x + let originY = self.vcView.frame.height /* Offset from top edge */ - 50.0 - pillView.addSubview(titleLabel) - pillView.addSubview(imageView) + NSAnimationContext.runAnimationGroup{ context in + context.duration = 1.0 + context.allowsImplicitAnimation = true + context.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) - UIView.animate(withDuration: 1) { - self.pillView.frame = CGRect(x: 100, - y: UIDevice.current.hasNotch ? 45: 25 + (self.isNavigationControllerPresent ? 40 : 0), - width: self.width, - height: self.height) - self.pillView.center.x = vcView.center.x + self.frame.origin = CGPoint(x: originX, y: originY) + // Need to center the pillView within the vcView + // Using autolayout constaints + //self.pillView.centerXAnchor.constraint(equalTo: vcView.centerXAnchor).isActive = true + //self.pillView.center.x = vcView.center.x } - - vcView.addSubview(pillView) - + #else + UIView.animate(withDuration: 1) { + self.frame = CGRect(x: 100, + y: UIDevice.current.hasNotch ? 45: 25 + (self.isNavigationControllerPresent ? 40 : 0), + width: self.width, + height: self.height) + self.center.x = vcView.center.x + } + #endif + DispatchQueue.main.asyncAfter(deadline: .now() + 1 + timeToShowErrorPill) { self.dismiss() } @@ -332,15 +451,77 @@ public class PillView { DispatchQueue.main.asyncAfter(deadline: .now() + 5 + timeToShowErrorPill) { imageView.removeFromSuperview() self.titleLabel.removeFromSuperview() + + // Fix Issue 25: PillView not removed from view hierarchy + self.removeFromSuperview() + self.showType = nil if let completionHandler = completionHandler { completionHandler() } } } + + /// Common configuration settings + private func configurePill(parentView: NSUIView) { + let originX = self.originForCenter(inRelationTo: parentView).x + let originY = parentView.frame.height /* Offset above top edge (positive value) */ + 50.0 + self.frame.origin = CGPoint(x: originX, y: originY) + + #if os(macOS) + // Define our shadow + let shadow = NSShadow() + shadow.shadowColor = NSUIColor.black.withAlphaComponent(0.2) + shadow.shadowOffset = CGSizeMake(0.0, -3.0) + shadow.shadowBlurRadius = 10.0 + + // Make our view layer-backed + self.wantsLayer = true + self.shadow = shadow + + // Define our pill property + self.layer!.backgroundColor = NSUIColor.PillboxBackgroundColor.cgColor + self.layer!.cornerRadius = 20 + self.layer!.borderColor = NSUIColor.lightGray.cgColor + self.layer!.borderWidth = 0.2 + + // And add resizing mask so both the left and right side have flexible margins + // and pill is horizontally centered into containing view when resizing + self.autoresizingMask = [.minXMargin, .maxXMargin] + + #else + let layer = self.layer + layer.backgroundColor = NSUIColor.PillboxBackgroundColor.cgColor + layer.cornerRadius = 20 + layer.shadowOpacity = 0.1 + layer.shadowOffset = .zero + layer.shadowColor = NSUIColor.black.cgColor + layer.shadowRadius = 10 + + // And add resizing mask so both the left and right side have flexible margins + // and pill is horizontally centered into containing view when resizing + self.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin] + #endif + + // Add it to the hierarchy and retain parent + parentView.addSubview(self) + self.vcView = parentView + } + + /// + /// Vertically center a subview within the pillView with the specified horizontal alignment + /// + private func centerVertically(subview: NSUIView, horizontalAlignment: NSRectAlignment) { + // TODO: This function is not doign anything useful yet... + let originX = subview.frame.origin.x + let originY = subview.frame.origin.y + subview.frame = CGRect(origin: CGPoint(x: originX, y: originY), size: subview.frame.size) + } } +#if os(iOS) extension UIDevice { internal var hasNotch: Bool { let bottom = UIApplication.shared.windows.filter {$0.isKeyWindow}.first?.safeAreaInsets.bottom ?? 0 return bottom > 0 } } +#endif diff --git a/Sources/PillboxView/VisualTransitions.swift b/Sources/PillboxView/VisualTransitions.swift index df4498a..dc0120a 100644 --- a/Sources/PillboxView/VisualTransitions.swift +++ b/Sources/PillboxView/VisualTransitions.swift @@ -3,9 +3,14 @@ // // // Created by Jacob Trentini on 2/3/22. -// +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) import UIKit +#endif + +import Foundation extension PillView { @@ -20,37 +25,65 @@ extension PillView { /// /// - Parameters: /// - animated: A Boolean indicating whether the ``PillboxView/PillView/pillView`` should be dismissed with an animation. + /// - timeBeforeMoveOut: Amount of time (in secs) before the ``PillboxView/PillView/pillView`` is moved outside the viewing frame /// - completionHandler: A completion handler indicating when the animation has finished. - open func dismiss(animated: Bool = true, completionHandler: (() -> Void)? = nil) { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + public func dismiss(animated: Bool = true, timeBeforeMoveOut: TimeInterval = 1.5, completionHandler: (() -> Void)? = nil) { + DispatchQueue.main.asyncAfter(deadline: .now() + timeBeforeMoveOut) { + #if os(macOS) + let originX = self.frame.origin.x + let originY = self.vcView.frame.height /* Distance above top (plus value) */ + 50 + NSAnimationContext.runAnimationGroup({ context in + context.duration = 1 + context.allowsImplicitAnimation = true + context.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn) + + self.frame.origin = CGPoint(x: originX, y: originY) + }, + completionHandler: { + if let completionHandler = completionHandler { completionHandler() } + }) + #else UIView.animate(withDuration: 1, delay: 0.25) { - self.pillView.frame = CGRect(x: self.pillView.frame.minX, - y: -300, - width: self.pillView.frame.width, - height: self.pillView.frame.height) + self.frame = CGRect(x: self.frame.minX, + y: -300, + width: self.frame.width, + height: self.frame.height) if let completionHandler = completionHandler { completionHandler() } } + #endif } } - /// Hides the ``PillboxView/PillView/pillView`` to the top of the screen. + /// Reveal the ``PillboxView/PillView/pillView`` to the top of the screen. /// /// The ``PillboxView/PillView/pillView`` moves to the top of the screen until it is in sight (it usually comes from being dismissed). /// /// - Parameters: /// - animated: A Boolean indicating whether the ``PillboxView/PillView/pillView`` should be revealed with an animation. /// - completionHandler: A completion handler indicating when the animation has finished. - open func reveal(animated: Bool = true, completionHandler: (() -> Void)? = nil) { + public func reveal(animated: Bool = true, completionHandler: (() -> Void)? = nil) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + #if os(macOS) + NSAnimationContext.runAnimationGroup{ context in + context.duration = 0.25 + context.allowsImplicitAnimation = true + context.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn) + + let originX = self.frame.origin.x + let originY = 25.0 + self.frame.origin = CGPoint(x: originX, y: originY) + } + #else UIView.animate(withDuration: 1, delay: 0.25) { - self.pillView.frame = CGRect(x: self.pillView.frame.minX, - y: UIDevice.current.hasNotch ? 45: 25 + (self.isNavigationControllerPresent ? 40 : 0), - width: self.pillView.frame.width, - height: self.pillView.frame.height) + self.frame = CGRect(x: self.frame.minX, + y: UIDevice.current.hasNotch ? 45: 25 + (self.isNavigationControllerPresent ? 40 : 0), + width: self.frame.width, + height: self.frame.height) if let completionHandler = completionHandler { completionHandler() } } + #endif } } }