Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AppKitNavigation - Common #209

Merged
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ let package = Package(
name: "UIKitNavigation",
targets: ["UIKitNavigation"]
),
.library(
name: "AppKitNavigation",
targets: ["AppKitNavigation"]
),
],
dependencies: [
.package(url: "https://github.com/apple/swift-collections", from: "1.0.0"),
Expand Down Expand Up @@ -75,6 +79,12 @@ let package = Package(
.target(
name: "UIKitNavigationShim"
),
.target(
name: "AppKitNavigation",
dependencies: [
"SwiftNavigation",
]
),
.testTarget(
name: "UIKitNavigationTests",
dependencies: [
Expand Down
11 changes: 11 additions & 0 deletions Package@swift-6.0.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ let package = Package(
name: "UIKitNavigation",
targets: ["UIKitNavigation"]
),
.library(
name: "AppKitNavigation",
targets: ["AppKitNavigation"]
),
],
dependencies: [
.package(url: "https://github.com/apple/swift-collections", from: "1.0.0"),
Expand Down Expand Up @@ -75,6 +79,13 @@ let package = Package(
.target(
name: "UIKitNavigationShim"
),
.target(
name: "AppKitNavigation",
dependencies: [
"SwiftNavigation",
.product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"),
]
),
.testTarget(
name: "UIKitNavigationTests",
dependencies: [
Expand Down
125 changes: 125 additions & 0 deletions Sources/AppKitNavigation/AppKitAnimation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
import AppKit

#if canImport(SwiftUI)
import SwiftUI
#endif

import SwiftNavigation

@MainActor
public func withAppKitAnimation<Result>(
_ animation: AppKitAnimation? = .default,
_ body: () throws -> Result,
completion: (@Sendable (Bool?) -> Void)? = nil
) rethrows -> Result {
var transaction = UITransaction()
transaction.appKit.animation = animation
if let completion {
transaction.appKit.addAnimationCompletion(completion)
}
return try withUITransaction(transaction, body)
}

public struct AppKitAnimation: Hashable, Sendable {
fileprivate let framework: Framework

@MainActor
func perform<Result>(
_ body: () throws -> Result,
completion: ((Bool?) -> Void)? = nil
) rethrows -> Result {
switch framework {
case let .appKit(animation):
var result: Swift.Result<Result, Error>?
NSAnimationContext.runAnimationGroup { context in
context.allowsImplicitAnimation = true
context.duration = animation.duration
context.timingFunction = animation.timingFunction
Comment on lines +36 to +38
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Mx-Iris I've familiarized myself a bit more with this API by adding a configurable timing function, and it also occurred to me that because withAppKitAnimation is intended to be called from the model, where view.animator is not available, that it probably makes sense to always set allowsImplicitAnimation to true. Does this sound right to you?

Copy link
Contributor Author

@Mx-Iris Mx-Iris Aug 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's right. NSView/NSWindow implements the NSAnimatablePropertyContainer protocol. When the view's frame, size, or position changes, it can produce animated effects. Through its animator, these properties can be used transparently to generate animations.

Setting NSAnimationContext's allowsImplicitAnimation to true, directly modifying the view's properties will produce animations. This is kind of like UIKit's UIView.animate method. But only the frame, frameSize, and frameOrigin properties are supported. More property animations require a layer-backed view, that is, the view's wantsLayer = true.

result = Swift.Result(catching: body)
} completionHandler: {
completion?(true)
}
return try result!._rethrowGet()

case let .swiftUI(animation):
var result: Swift.Result<Result, Error>?
#if swift(>=6)
if #available(macOS 15, *) {
NSAnimationContext.animate(animation) {
result = Swift.Result(catching: body)
} completion: {
completion?(true)
}
return try result!._rethrowGet()
}
#endif
_ = animation
fatalError()
}
}

fileprivate enum Framework: Hashable, Sendable {
case appKit(AppKit)
case swiftUI(Animation)

fileprivate struct AppKit: Hashable, @unchecked Sendable {
fileprivate var duration: TimeInterval
fileprivate var timingFunction: CAMediaTimingFunction?

func hash(into hasher: inout Hasher) {
hasher.combine(duration)
}
}
}
}

extension AppKitAnimation {
@available(macOS 15, *)
public init(_ animation: Animation) {
self.init(framework: .swiftUI(animation))
}

public static func animate(
duration: TimeInterval = 0.25,
timingFunction: CAMediaTimingFunction? = nil
) -> Self {
Self(
framework: .appKit(
Framework.AppKit(
duration: duration,
timingFunction: timingFunction
)
)
)
}

public static var `default`: Self {
.animate()
}

public static var linear: Self { .linear(duration: 0.25) }

public static func linear(duration: TimeInterval) -> Self {
.animate(duration: duration, timingFunction: CAMediaTimingFunction(name: .linear))
}

public static var easeIn: Self { .easeIn(duration: 0.25) }

public static func easeIn(duration: TimeInterval) -> Self {
.animate(duration: duration, timingFunction: CAMediaTimingFunction(name: .easeIn))
}

public static var easeOut: Self { .easeOut(duration: 0.25) }

public static func easeOut(duration: TimeInterval) -> Self {
.animate(duration: duration, timingFunction: CAMediaTimingFunction(name: .easeOut))
}

public static var easeInOut: Self { .easeInOut(duration: 0.25) }

public static func easeInOut(duration: TimeInterval) -> Self {
.animate(duration: duration, timingFunction: CAMediaTimingFunction(name: .easeInEaseOut))
}
}
#endif
3 changes: 3 additions & 0 deletions Sources/AppKitNavigation/Internal/Exports.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
@_exported import SwiftNavigation
#endif
11 changes: 11 additions & 0 deletions Sources/AppKitNavigation/UIBinding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
import SwiftNavigation

extension UIBinding {
public func animation(_ animation: AppKitAnimation? = .default) -> Self {
var binding = self
binding.transaction.appKit.animation = animation
return binding
}
}
#endif
75 changes: 75 additions & 0 deletions Sources/AppKitNavigation/UITransaction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
import AppKit
import SwiftNavigation

extension UITransaction {
public init(animation: AppKitAnimation? = nil) {
self.init()
appKit.animation = animation
}

public var appKit: AppKit {
get { self[AppKitKey.self] }
set { self[AppKitKey.self] = newValue }
}

private enum AppKitKey: _UICustomTransactionKey {
static let defaultValue = AppKit()

static func perform(
value: AppKit,
operation: @Sendable () -> Void
) {
MainActor._assumeIsolated {
if value.disablesAnimations {
NSAnimationContext.runAnimationGroup { context in
context.allowsImplicitAnimation = false
operation()
}
for completion in value.animationCompletions {
completion(true)
}
} else if let animation = value.animation {
return animation.perform(
{ operation() },
completion: value.animationCompletions.isEmpty
? nil
: {
for completion in value.animationCompletions {
completion($0)
}
}
)
} else {
operation()
for completion in value.animationCompletions {
completion(true)
}
}
}
}
}

public struct AppKit: Sendable {
public var animation: AppKitAnimation?

public var disablesAnimations = false

var animationCompletions: [@Sendable (Bool?) -> Void] = []

public mutating func addAnimationCompletion(
_ completion: @escaping @Sendable (Bool?) -> Void
) {
animationCompletions.append(completion)
}
}
}

private enum AnimationCompletionsKey: UITransactionKey {
static let defaultValue: [@Sendable (Bool?) -> Void] = []
}

private enum DisablesAnimationsKey: UITransactionKey {
static let defaultValue = false
}
#endif
19 changes: 19 additions & 0 deletions Sources/SwiftNavigation/Internal/ErrorMechanism.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@rethrows
package protocol _ErrorMechanism {
associatedtype Output
func get() throws -> Output
}

extension _ErrorMechanism {
package func _rethrowError() rethrows -> Never {
_ = try _rethrowGet()
fatalError()
}

package func _rethrowGet() rethrows -> Output {
return try get()
}
}

extension Result: _ErrorMechanism {}

12 changes: 12 additions & 0 deletions Sources/SwiftNavigation/Internal/ToOptionalUnit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
extension Bool {
package struct Unit: Hashable, Identifiable {
package var id: Unit { self }

package init() {}
}

package var toOptionalUnit: Unit? {
get { self ? Unit() : nil }
set { self = newValue != nil }
}
}
20 changes: 0 additions & 20 deletions Sources/UIKitNavigation/Internal/ErrorMechanism.swift

This file was deleted.

12 changes: 0 additions & 12 deletions Sources/UIKitNavigation/Internal/ToOptionalUnit.swift

This file was deleted.

2 changes: 1 addition & 1 deletion Tests/SwiftNavigationTests/LifetimeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

@Perceptible
@MainActor
class Model {
private class Model {
var count = 0
}
#endif
Loading