-
Notifications
You must be signed in to change notification settings - Fork 141
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
Expose UITransaction APIs under @_spi #277
base: main
Are you sure you want to change the base?
Conversation
- UITransaction.current - UIKitAnimation.Framework - AppKitAnimation.Framework
Hi @maximkrouk, thanks for taking the time to PR. Can you provide a concrete example of how you want to make use of |
Yep, in my case it would be smth like class BlurReplaceContainer<Content: UIView> {
let animator: CADisplayLinkAnimator = .init()
// I have to pass transaction here, but I'm lucky I have observable model in parent component
// however I would prefer to get it implicitly to avoid passing it through 2-3 levels of components
// ––––––––––––––––––↴
func show(_ show: Bool) {
// ... initial setup, some stuff should be wrapped in UIView.performWithoutAnimation
// to avoid implicit animation
let transaction = UITransaction.current // ←
guard
transaction.uiKit.disablesAnimations == false,
let animation = transaction.uiKit.animation
else {
// update with no animation
}
// Currently in this init I parse parameters from the dump 😅
let baseAnimation: CADisplayLinkAnimator.Animation = .init(
animation
)
animator.animations.append(reduce(baseAnimation) { animation in
animation.tick = { progress in
// handle initial snapshot image blur with CIFilter
}
})
// ...
}
} |
@maximkrouk Rather than expose destination(isPresented: $isPresented) { [weak self] in
self?.blurContainer
} present: { container, transaction in
container.show(
true,
animated: !transaction.uiKit.disablesAnimations
)
} dismiss: { container, transaction in
container.show(
false,
animated: !transaction.uiKit.disablesAnimations
)
} The first closure could also create the container and then As for passing animation data along, how would you want to implement If the animation is better modeled as its own data type, you could instead pass a custom |
For now I have a brief prototype, animation declaration looks like this public struct Animation {
public var duration: TimeInterval
public var delay: TimeInterval
// basically `(_ t: Double) -> Double`
// I have Linear and CubicBezier curves, second one
// can be initialized with CAMediaTimingFunction
// Spring is in todo
public var easingCurve: EasingCurveProtocol
public var tick: (Double) -> Void
} Params are filled using native enum Keys: String, CaseIterable {
case delay
case duration
case speed
// bezier curve params for SwiftUI.Animation
case ax, bx, cx, ay, by, cy
}
I'm still experimenting with the API, and I'm even kinda ok with parsing animation params (tho I'd leverage swift-parsing in the future, my prototype is yet just a bunch of While writing this I came up with another idea for such components // Outside of context of our discussion it probably doesn't make much sense
// so I still like @_spi-exposed transaction more :)
public protocol AnimationContextAccessible {
var currentAnimation: UIKitAnimation? { get }
}
extension AnimationContextAccessible {
public var currentAnimation: UIKitAnimation? {
UITransaction.current.uiKit.disablesAnimations ? nil : UITransaction.current.uiKit.animation
}
}
If it turn out to be something cool and ergonomic I'll opensource some stuff like import ExistentialContainer
import SwiftUI
private protocol ViewConforming {
var content: AnyView { get }
}
extension ExistentialContainer: ViewConforming where Base: View {
fileprivate var content: AnyView { AnyView(base) }
}
extension AnyView {
public init?(any: Any) {
guard let content = open(any, \ViewConforming.content) else { return nil }
self = content
}
} |
Core of bezier easing curve: public struct CubicBezier {
public var ax, bx, cx: Double
public var ay, by, cy: Double
public var x0, y0: Double
// this format corresponds to SUI eased functions
public init(
ax: Double, bx: Double, cx: Double,
ay: Double, by: Double, cy: Double,
x0: Double = 0, y0: Double = 0
) {
self.ax = ax
self.bx = bx
self.cx = cx
self.ay = ay
self.by = by
self.cy = cy
self.x0 = x0
self.y0 = y0
}
// CAMediaTimingFunction init just extracts control points and calls this one
public init(
p0: Point<Double> = .init(x: 0, y: 0),
p1: Point<Double>,
p2: Point<Double>,
p3: Point<Double> = .init(x: 1, y: 1)
) {
let x0 = p0.x
let y0 = p0.y
let cx = 3 * (p1.x - p0.x)
let bx = 3 * (p2.x - p1.x) - cx
let ax = p3.x - p0.x - cx - bx
let cy = 3 * (p1.y - p0.y)
let by = 3 * (p2.y - p1.y) - cy
let ay = p3.y - p0.y - cy - by
self.init(
ax: ax, bx: bx, cx: cx,
ay: ay, by: by, cy: cy,
x0: x0, y0: y0
)
}
// used by animator
public func value(for t: Double) -> Double {
ease(t: t).y
}
public func ease(t: Double) -> Point<Double> {
let t2 = t * t
let t3 = t2 * t
let x = ax * t3 + bx * t2 + cx * t
let y = ay * t3 + by * t2 + cy * t
return Point(x: x, y: y)
}
// Not used yet, but might be helpful if I'll need to create a custom CAMediaTimingFunction
public func controlPoints() -> (
p1: Point<Double>,
c1: Point<Double>,
c2: Point<Double>,
p2: Point<Double>
) {
let p1 = Point(
x: x0,
y: y0
)
let c1 = Point(
x: cx / 3 + x0,
y: cy / 3 + y0
)
let c2 = Point(
x: bx / 3 + c1.x,
y: by / 3 + c1.y
)
let p2 = Point(
x: ax + c2.x,
y: ay + c2.y
)
return (p1, c1, c2, p2)
}
} |
As I mentioned before, you could traffic your custom animation type through a custom } present: { container, transaction in
container.show(
true,
animation: transaction.displayLinkAnimation // You define this
)
} dismiss: { container, transaction in
Yeah,
My suggestion was that the
Nesting these animation functions seems like it should work just fine and be more flexible, the same way that
While this is theoretically true, there are plenty of times when accessing the |
Keep us posted 😄 Especially if you ship a |
Convertion is hacky, but it's implementation detail, I think it is worth it if the call site is more ergonomic withUIKitAnimation(.easeInOut) {
// logic
} vs withUIKitAnimation(.easeInOut) {
withMyCustomAnimation(.easeInOut) { // ← Args duplication
// logic ← higher nesting
}
} And also at the call site I don't want to care about implementation details of all child components
I can see multiple solutions that will work, it's one of them, but it'll require passing transaction from the outside, basically as I did (just with
It's the other one, but it has the same issues + UIBinding is 2-way and in my case data is only set from the outside 💁♂️
Yes, but it's 2 different frameworks and UIKit has smth like
It may provide it with some My final solution for now is extension UITransaction {
public static var current: UITransaction {
let transaction = LockIsolated(UITransaction())
observe { transaction.setValue($0) }.cancel()
return transaction.value
}
} 😅 |
I just want to emphasize that we would prefer flexibility here in the library itself, and you could write your own wrapper that flattens things if you prefer.
Please reread my comment. The example does not pass the transaction in from the outside, but merely the animation data trafficked through it. Edit: Oops, accidentally posted to soon! Continuing:
A binding is also an observable source of truth, so even though you only set it from your model, the helper benefits from this observation. This is similar to presenting a full screen cover that is never dismissed via
I mentioned this already, so please do reread my post in case you missed anything. As it stands the scoped methods are given transactions at very specific points where they are valid to use. Having a global available at any time means accessing it at invalid times and places outside of a current transaction being valid. One shouldn't reach for
I don't understand, are you saying you are able to override this static locally and intercept mutations to the parent? That seems like a Swift bug to me and I would urge you not to exploit it 😅 |
Yep, I was referring to access to current transaction to get args for the animation from some parent, in my case it was transaction itself, but it could be some key from transaction like animation or custom one 💁♂️
Fair, my thoughts were corrupted by SUI where there observation is kinda automatic and
Hmm, I don't mutate anything and use it on MainActor in a non-escaping manner just to get animation params, so with some tests for parsing params I should be safe enough I guess.
Agree on "it wouldn't make sense", when one uses @_spi apis they should consider risks and in my case I feel like it makes sense, probably one of the very few such cases. Misuse of @_spi apis is natural selection and lib is not responsible for one's mistakes 💁♂️ So to sum things up:
Now I don't have any arguments for exposing those APIs except: "As an enthusiast I like when libs treat their users as adults and provide access to implementation details. It would be a bad design if implementation details are exposed in public API (namespace pollution, potential misuse), but we have a very specific tool to expose those APIs privately (I think average devs are not even familiar with @_spi, not saying it in a bad way, it's just a very specific thing)" I'm down to close the PR if this argument is not enough 💁♂️
Update/Edit: Just saw one of the recent PRs with adding |
UITransaction.current
UIKitAnimation.Framework
AppKitAnimation.Framework
Even though
swift-navigation
animates stuff by default, some animations should be handled manually (e.g., CADisplayLink-based animations).The general audience probably doensn't need these APIs so I believe @_spi is a good way to expose them 💁♂️