This commit is contained in:
Sindre Sorhus 2023-05-17 20:05:30 +07:00
parent 4e5a5fdfff
commit d83c83ba4e
7 changed files with 263 additions and 72 deletions

4
.spi.yml Normal file
View File

@ -0,0 +1,4 @@
version: 1
builder:
configs:
- documentation_targets: ['DockProgress']

View File

@ -1,6 +1,7 @@
only_rules:
- anyobject_protocol
- accessibility_trait_for_button
- array_init
- blanket_disable_command
- block_based_kvo
- class_delegate_protocol
- closing_brace
@ -10,6 +11,7 @@ only_rules:
- collection_alignment
- colon
- comma
- comma_inheritance
- compiler_protocol_init
- computed_accessors_order
- conditional_returns_on_newline
@ -20,6 +22,7 @@ only_rules:
- control_statement
- custom_rules
- deployment_target
- direct_return
- discarded_notification_center_observer
- discouraged_assert
- discouraged_direct_init
@ -27,6 +30,7 @@ only_rules:
- discouraged_object_literal
- discouraged_optional_boolean
- discouraged_optional_collection
- duplicate_conditions
- duplicate_enum_cases
- duplicate_imports
- duplicated_key_in_dictionary_literal
@ -52,7 +56,7 @@ only_rules:
- implicit_getter
- implicit_return
- inclusive_language
- inert_defer
- invalid_swiftlint_command
- is_disjoint
- joined_default_parameter
- last_where
@ -68,7 +72,6 @@ only_rules:
- lower_acl_than_parent
- mark
- modifier_order
- multiline_arguments
- multiline_function_chains
- multiline_literal_brackets
- multiline_parameters
@ -78,13 +81,14 @@ only_rules:
- no_fallthrough_only
- no_space_in_method_call
- notification_center_detachment
- ns_number_init_as_function_reference
- nsobject_prefer_isequal
- number_separator
- opening_brace
- operator_usage_whitespace
- operator_whitespace
- orphaned_doc_comment
- overridden_super_call
- prefer_self_in_static_references
- prefer_self_type_over_type_of_self
- prefer_zero_over_explicit_init
- private_action
@ -105,12 +109,17 @@ only_rules:
- redundant_void_return
- required_enum_case
- return_arrow_whitespace
- return_value_from_void_function
- self_binding
- self_in_property_initialization
- shorthand_operator
- shorthand_optional_binding
- sorted_first_last
- statement_position
- static_operator
- strong_iboutlet
- superfluous_disable_command
- superfluous_else
- switch_case_alignment
- switch_case_on_newline
- syntactic_sugar
@ -121,12 +130,12 @@ only_rules:
- trailing_newline
- trailing_semicolon
- trailing_whitespace
- unavailable_condition
- unavailable_function
- unneeded_break_in_switch
- unneeded_parentheses_in_closure_argument
- unowned_variable_capture
- untyped_error_in_catch
- unused_capture_list
- unused_closure_parameter
- unused_control_flow_label
- unused_enumerated
@ -134,9 +143,9 @@ only_rules:
- unused_setter_value
- valid_ibinspectable
- vertical_parameter_alignment
- vertical_parameter_alignment_on_call
- vertical_whitespace_closing_braces
- vertical_whitespace_opening_braces
- void_function_in_ternary
- void_return
- xct_specific_matcher
- xctfail_message
@ -145,6 +154,9 @@ analyzer_rules:
- capture_variable
- unused_declaration
- unused_import
- typesafe_array_init
for_where:
allow_for_as_filter: true
number_separator:
minimum_length: 5
identifier_name:
@ -154,7 +166,6 @@ identifier_name:
min_length:
warning: 2
error: 2
validates_start_with_lowercase: false
allowed_symbols:
- '_'
excluded:
@ -199,3 +210,6 @@ custom_rules:
final_class:
regex: '^class [a-zA-Z\d]+[^{]+\{'
message: 'Classes should be marked as final whenever possible. If you actually need it to be subclassable, just add `// swiftlint:disable:next final_class`.'
no_alignment_center:
regex: '\b\(alignment: .center\b'
message: 'This alignment is the default.'

View File

@ -20,7 +20,7 @@ final class AppState: ObservableObject {
.bar,
.squircle(color: .systemGray),
.circle(radius: 30, color: .white),
.badge(color: .systemBlue) { Int(DockProgress.animatedProgress * 12) },
.badge(color: .systemBlue) { Int(DockProgress.displayedProgress * 12) },
.pie(color: .systemBlue)
]
@ -30,15 +30,17 @@ final class AppState: ObservableObject {
DockProgress.resetProgress()
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
DockProgress.progress += 0.2
Task { @MainActor in
DockProgress.progress += 0.2
if DockProgress.animatedProgress >= 1 {
if let style = stylesIterator.next() {
DockProgress.resetProgress()
DockProgress.style = style
} else {
// Reset iterator when all is looped.
stylesIterator = styles.makeIterator()
if DockProgress.displayedProgress >= 1 {
if let style = stylesIterator.next() {
DockProgress.resetProgress()
DockProgress.style = style
} else {
// Reset iterator when all is looped.
stylesIterator = styles.makeIterator()
}
}
}
}

View File

@ -1,4 +1,4 @@
// swift-tools-version:5.7
// swift-tools-version:5.8
import PackageDescription
let package = Package(

View File

@ -1,22 +1,34 @@
import Cocoa
/**
Show progress in your app's Dock icon.
Use either ``progress`` or ``progressInstance``.
*/
@MainActor
public enum DockProgress {
private static var progressObserver: NSKeyValueObservation?
private static var finishedObserver: NSKeyValueObservation?
private static var elapsedTimeSinceLastRefresh = 0.0
private static var displayLinkObserver = DisplayLinkObserver { (displayLinkObserver, refreshPeriod) in
private static var displayLinkObserver = DisplayLinkObserver { displayLinkObserver, refreshPeriod in
DispatchQueue.main.async {
let speed = 1.0
elapsedTimeSinceLastRefresh += speed * refreshPeriod
if (animatedProgress - progress).magnitude <= 0.01 {
animatedProgress = progress
if (displayedProgress - progress).magnitude <= 0.01 {
displayedProgress = progress
elapsedTimeSinceLastRefresh = 0
displayLinkObserver.stop()
} else {
animatedProgress = Easing.lerp(animatedProgress, progress, Easing.easeInOut(elapsedTimeSinceLastRefresh));
displayedProgress = Easing.linearInterpolation(
start: displayedProgress,
end: progress,
progress: Easing.easeInOut(progress: elapsedTimeSinceLastRefresh)
)
}
updateDockIcon()
}
}
@ -25,6 +37,23 @@ public enum DockProgress {
NSApp.dockTile.contentView = $0
}
/**
Assign a [`Progress`](https://developer.apple.com/documentation/foundation/progress) instance to track the progress status.
When set to `nil`, the progress will be reset.
The given `Progress` instance is weakly stored. It's up to you to retain it.
```swift
import Foundation
import DockProgress
let progress = Progress(totalUnitCount: 1)
progress?.becomeCurrent(withPendingUnitCount: 1)
DockProgress.progressInstance = progress
```
*/
public static weak var progressInstance: Progress? {
didSet {
guard let progressInstance else {
@ -63,6 +92,17 @@ public enum DockProgress {
}
}
/**
Indicates the current progress from 0.0 to 1.0. Setting this value will start the animation towards the set value.
```swift
import DockProgress
foo.onUpdate = { progress in
DockProgress.progress = progress
}
```
*/
public static var progress: Double = 0 {
didSet {
if progress > 0 {
@ -74,46 +114,120 @@ public enum DockProgress {
}
/**
The currently displayed progress (readonly). Animates towards `progress`
The currently displayed progress. Animates towards ``progress``.
*/
public private(set) static var animatedProgress = 0.0
public private(set) static var displayedProgress = 0.0
/**
Reset the `progress` without animating.
Reset the progress without animating.
*/
public static func resetProgress() {
displayLinkObserver.stop()
progress = 0
animatedProgress = 0
elapsedTimeSinceLastRefresh = 0;
displayedProgress = 0
elapsedTimeSinceLastRefresh = 0
updateDockIcon()
}
/**
The available progress styles.
- `.bar` ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-bar.gif?raw=true)
- `.squircle` ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-squircle.gif?raw=true)
- `.circle` ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-circle.gif?raw=true)
- `.badge` ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-badge.gif?raw=true)
- `.pie` ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-pie.gif?raw=true)
*/
public enum Style {
/**
Progress bar style.
![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-bar.gif?raw=true)
*/
case bar
/**
Progress line animating around the edges of the app icon.
- Parameters:
- inset: Inset value to adjust the squircle shape. By default, it should fit a normal macOS icon.
- color: The color of the progress.
![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-squircle.gif?raw=true)
*/
case squircle(inset: Double? = nil, color: NSColor = .controlAccentColor)
/**
Circle style.
- Parameters:
- radius: The radius of the circle.
- color: The color of the progress.
![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-circle.gif?raw=true)
*/
case circle(radius: Double, color: NSColor = .controlAccentColor)
/**
Badge style.
- Parameters:
- color: The color of the badge.
- badgeValue: A closure that returns the badge value as an integer.
- Note: It is not meant to be used as a numeric percentage. It's for things like count of downloads, number of files being converted, etc.
Large badge value numbers will be written in kilo short notation, for example, `1012` `1k`.
![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-badge.gif?raw=true)
*/
case badge(color: NSColor = .controlAccentColor, badgeValue: () -> Int)
/**
Pie style.
- Parameters:
- color: The color of the pie.
![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-pie.gif?raw=true)
*/
case pie(color: NSColor = .controlAccentColor)
/**
Custom style.
- Parameters:
- drawHandler: A closure that is responsible for drawing the custom progress.
*/
case custom(drawHandler: (_ rect: CGRect) -> Void)
}
/**
The style to be used for displaying progress.
The default style is `.bar`.
Check out the example app in the Xcode project for a demo of the styles.
*/
public static var style = Style.bar
// TODO: Make the progress smoother by also animating the steps between each call to `updateDockIcon()`
private static func updateDockIcon() {
dockContentView.needsDisplay = true;
dockContentView.needsDisplay = true
NSApp.dockTile.display()
}
private class ContentView: NSView {
private final class ContentView: NSView {
override func draw(_ dirtyRect: NSRect) {
NSGraphicsContext.current?.imageInterpolation = .high
NSApp.applicationIconImage?.draw(in: dirtyRect)
// TODO: If the `progress` is 1, draw the full circle, then schedule another draw in n milliseconds to hide it
if (animatedProgress <= 0 || animatedProgress >= 1) {
guard
displayedProgress > 0,
displayedProgress < 1
else {
return
}
@ -148,7 +262,7 @@ public enum DockProgress {
roundedRect(barInnerBg)
var barProgress = bar.insetBy(dx: 1, dy: 1)
barProgress.size.width = barProgress.width * animatedProgress
barProgress.size.width = barProgress.width * displayedProgress
NSColor.white.set()
roundedRect(barProgress)
}
@ -169,7 +283,7 @@ public enum DockProgress {
let progressSquircle = ProgressSquircleShapeLayer(rect: rect)
progressSquircle.strokeColor = color.cgColor
progressSquircle.lineWidth = 5
progressSquircle.progress = animatedProgress
progressSquircle.progress = displayedProgress
progressSquircle.render(in: cgContext)
}
@ -181,7 +295,7 @@ public enum DockProgress {
let progressCircle = ProgressCircleShapeLayer(radius: radius, center: dstRect.center)
progressCircle.strokeColor = color.cgColor
progressCircle.lineWidth = 4
progressCircle.progress = animatedProgress
progressCircle.progress = displayedProgress
progressCircle.render(in: cgContext)
}
@ -209,7 +323,7 @@ public enum DockProgress {
progressCircle.strokeColor = color.cgColor
progressCircle.lineWidth = lineWidth
progressCircle.lineCap = .butt
progressCircle.progress = animatedProgress
progressCircle.progress = displayedProgress
// Label
if !isPie {
@ -246,11 +360,13 @@ public enum DockProgress {
if absNumber < 1000 {
return "\(number)"
} else if absNumber < 10_000 {
return "\(sign * Int(absNumber / 1000))k"
} else {
return "\(sign * 9)k+"
}
if absNumber < 10_000 {
return "\(sign * Int(absNumber / 1000))k"
}
return "\(sign * 9)k+"
}
private static func scaledBadgeFontSize(text: String) -> Double {

View File

@ -271,56 +271,112 @@ final class VerticallyCenteredTextLayer: CATextLayer {
}
}
/**
Provides functions for linear interpolation and easing effects.
These functions are useful for animations and transitions, or anywhere you want to smoothly transition between two values.
*/
enum Easing {
static func lerp(_ start: Double, _ end: Double, _ t: Double) -> Double {
return Double(simd_mix(Float(start), Float(end), Float(t)))
/**
Linearly interpolates between two values.
Also known as `lerp`.
- Parameters:
- start: The start value.
- end: The end value.
- progress: The interpolation progress as a decimal between 0.0 and 1.0.
- Returns: The interpolated value.
*/
static func linearInterpolation(start: Double, end: Double, progress: Double) -> Double {
assert(0...1 ~= progress, "Progress must be between 0.0 and 1.0")
return Double(simd_mix(Float(start), Float(end), Float(progress)))
}
static private func easeIn(_ t: Double) -> Double {
return Double(simd_smoothstep(0.0, 1.0, Float(t)))
/**
Provides an ease-in effect.
- Parameter progress: The progress as a decimal between 0.0 and 1.0.
- Returns: The eased value.
*/
static private func easeIn(progress: Double) -> Double {
assert(0...1 ~= progress, "Progress must be between 0.0 and 1.0")
return Double(simd_smoothstep(0.0, 1.0, Float(progress)))
}
static private func easeOut(_ t: Double) -> Double {
return 1 - easeIn(1 - t)
/**
Provides an ease-out effect.
- Parameter progress: The progress as a decimal between 0.0 and 1.0.
- Returns: The eased value.
*/
static private func easeOut(progress: Double) -> Double {
assert(0...1 ~= progress, "Progress must be between 0.0 and 1.0")
return 1 - easeIn(progress: 1 - progress)
}
static func easeInOut(_ t: Double) -> Double {
return lerp(easeIn(t), easeOut(t), t)
/**
Provides an ease-in-out effect.
- Parameter progress: The progress as a decimal between 0.0 and 1.0.
- Returns: The eased value.
*/
static func easeInOut(progress: Double) -> Double {
assert(0...1 ~= progress, "Progress must be between 0.0 and 1.0")
return linearInterpolation(
start: easeIn(progress: progress),
end: easeOut(progress: progress),
progress: progress
)
}
}
typealias DisplayLinkObserverCallback = (DisplayLinkObserver, Double) -> Void;
class DisplayLinkObserver {
/**
An observer that invokes a callback for each screen refresh.
This is useful for creating smooth animations that synchronize with the screen's refresh rate.
*/
final class DisplayLinkObserver {
private var displayLink: CVDisplayLink?
var callback: DisplayLinkObserverCallback
fileprivate let callback: (DisplayLinkObserver, Double) -> Void
init(_ callback: @escaping DisplayLinkObserverCallback) {
init(_ callback: @escaping (DisplayLinkObserver, Double) -> Void) {
self.callback = callback
let result = CVDisplayLinkCreateWithActiveCGDisplays(&displayLink)
assert(result == kCVReturnSuccess, "Failed to create CVDisplayLink")
assert(CVDisplayLinkCreateWithActiveCGDisplays(&displayLink) == kCVReturnSuccess, "Failed to create CVDisplayLink")
}
deinit {
stop()
}
func start() {
if let displayLink {
let result = CVDisplayLinkSetOutputCallback(
displayLink,
displayLinkOutputCallback,
UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
)
assert(result == kCVReturnSuccess, "Failed to set CVDisplayLink output callback")
if (CVDisplayLinkStart(displayLink) != kCVReturnSuccess) {
print("Warning: CVDisplayLink already running")
}
guard let displayLink else {
return
}
let result = CVDisplayLinkSetOutputCallback(
displayLink,
displayLinkOutputCallback,
UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
)
assert(result == kCVReturnSuccess, "Failed to set CVDisplayLink output callback")
CVDisplayLinkStart(displayLink)
}
func stop() {
if let displayLink {
if (CVDisplayLinkStop(displayLink) != kCVReturnSuccess) {
print("Warning: CVDisplayLink already stopped")
}
guard let displayLink else {
return
}
CVDisplayLinkStop(displayLink)
}
}
@ -333,11 +389,14 @@ private func displayLinkOutputCallback(
displayLinkContext: UnsafeMutableRawPointer?
) -> CVReturn {
let observer = unsafeBitCast(displayLinkContext, to: DisplayLinkObserver.self)
var refreshPeriod = CVDisplayLinkGetActualOutputVideoRefreshPeriod(displayLink)
if (refreshPeriod == 0) {
print("Warning: CVDisplayLinkGetActualOutputVideoRefreshPeriod failed. Assuming 60 Hz...")
refreshPeriod = 1.0 / 60.0
}
observer.callback(observer, refreshPeriod)
return kCVReturnSuccess
}

View File

@ -4,7 +4,7 @@
<img src="screenshot.gif" width="485">
This package is used in production by the [Gifski app](https://github.com/sindresorhus/Gifski). You might also like some of my [other apps](https://sindresorhus.com/apps).
This package is used in production by the [Gifski app](https://github.com/sindresorhus/Gifski). You may also like some of my [other apps](https://sindresorhus.com/apps).
## Requirements
@ -84,8 +84,6 @@ import DockProgress
DockProgress.style = .circle(radius: 55, color: .systemBlue)
```
Make sure to set a `radius` that matches your app icon.
### Badge
![](screenshot-badge.gif)
@ -113,8 +111,6 @@ DockProgress.style = .pie(color: .systemBlue)
## Related
- [Defaults](https://github.com/sindresorhus/Defaults) - Swifty and modern UserDefaults
- [Preferences](https://github.com/sindresorhus/Preferences) - Add a preferences window to your macOS app in minutes
- [KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts) - Add user-customizable global keyboard shortcuts to your macOS app
- [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Add "Launch at Login" functionality to your macOS app
- [Regex](https://github.com/sindresorhus/Regex) - Swifty regular expressions
- [More…](https://github.com/search?q=user%3Asindresorhus+language%3Aswift)