Skip to content

Commit

Permalink
Add support for macOS (AppKit) using the NSUI package (#29)
Browse files Browse the repository at this point in the history
* Add macOS (AppKit) compatibility using the UXKit package
Directly derive PillView from UXView
Add ability to specify a custom font to display the pill message
Ensure pillView is centered within the container view
Add ability to specify a different message when `completeTask` is called
Comment out Hashable conformance because this is coming for free by dericing from UXView
Rework initializer structure to use convenience init
Fix issue where pillView was not removed from the view hierarchy when dismissed
WIP: Add PillAnimation structure to allow pill to be animated from the bottom or from the top

* Ensure UXImage with symbol name works for both macOS (11.x +) and iOS

* Update .gitignore
Add extensions to NSUI

* Update PillBox to use NSUI for macOS compatibility
Derive PillBox directly from NSUIView instead of using containment
Comment out Conformance as no longer needed
Support AppKit for PillView transition
Allow specification to sticky timeout for showError()
Allow specification of a default Font for PillView
Allow capability to update the showTask() message while in process
Properly center the pillView into the container view using simple autoresizing masks
Extend NSUIColor to get light and dark color scheme

* Add ci.yml to build on supported platforms...

* Update ci to support Mac Catalyst

* Update ci to support Mac Catalyst

* Update ci.yml

* Trying to fix ci again.

* Update ci.yml

* Update ci.yml

* Update ci.yml

* Update all NS* reference to use the common CoreGraphics CG* functions to help with AppKit / UIKit compatibility

* Vertically center PillView subviews according to font line height and pillView heights
Ensure font size does not exceed PillView height
Rename files to start with Pill*
  • Loading branch information
martindufort authored Feb 14, 2024
1 parent d5f7cbf commit 971e784
Show file tree
Hide file tree
Showing 12 changed files with 600 additions and 223 deletions.
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@

*.xcuserstate
## User settings
xcuserdata/
**/.swiftpm
**/.DS_Store
*.xcuserstate
13 changes: 9 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Binary file removed Sources/.DS_Store
Binary file not shown.
4 changes: 4 additions & 0 deletions Sources/PillboxView/Conformance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -34,3 +37,4 @@ extension PillView: Hashable {
hasher.combine(vcView)
}
}
*/
48 changes: 48 additions & 0 deletions Sources/PillboxView/NSUI+Extensions.swift
Original file line number Diff line number Diff line change
@@ -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
}

46 changes: 37 additions & 9 deletions Sources/PillboxView/PillColors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
23 changes: 23 additions & 0 deletions Sources/PillboxView/PillPosition.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// PillPosition.swift
// ---

/// Defines the position of the ``PillboxView/PillView`` respective to it's view container as an offset
/// Also provide the ability to show the ``PillboxView/PillView`` appearing from the top ``fromTop`` or from the bottom
/// ``fromBottom`` of the container's edge
/// Default position is `.fromTop` with and offset of 25
import CoreGraphics

public struct PillPosition {
enum AnimationDirection {
case fromTop
case fromBottom
}
var direction: AnimationDirection
var offsetFromEdge: CGFloat

init() {
self.direction = .fromTop
self.offsetFromEdge = CGFloat(25.0)
}
}
92 changes: 92 additions & 0 deletions Sources/PillboxView/PillTransitions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//
// PillTransitions.swift
//
//
// Created by Jacob Trentini on 2/3/22.

#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

import Foundation

extension PillView {

/// Hides the ``PillboxView/PillView/pillView`` to the top of the screen.
///
/// The ``PillboxView/PillView/pillView`` moves up to the top of the screen until it is out of sight.
/// This is used within the ``PillboxView/PillView/completedTask(state:completionHandler:)`` and ``PillboxView/PillView/showError(message:vcView:)``.
///
/// The animation, in total, takes 3 seconds to complete.
///
/// This does not reset or de-initialize any values of the ``PillboxView/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.
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: {
completionHandler?()
})
#else
UIView.animate(withDuration: 1.0,
animations: {
// Only change the position of the origin Y axis to be above the container
self.frame.origin = CGPoint(x: self.frame.origin.x, y: 0 - self.frame.height)
},
completion: { completed in
if completed { completionHandler?() }
})
#endif
}
}

/// 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.
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,
animations: {
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)
},
completion: { completed in
if completed { completionHandler?() }
})
#endif
}
}
}
Loading

0 comments on commit 971e784

Please sign in to comment.