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

Expose UITransaction APIs under @_spi #277

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

maximkrouk
Copy link

@maximkrouk maximkrouk commented Mar 4, 2025

  • 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).

  • Accessing Transaction.current allows for implicit checks of animations in the current context.
  • Accessing CocoaAnimation.framework enables the extraction of parameters for Cocoa animations without reflection (tho SwiftUI will still require reflection, but at least the Cocoa part will be cleaner 🌚)

The general audience probably doensn't need these APIs so I believe @_spi is a good way to expose them 💁‍♂️

- UITransaction.current
- UIKitAnimation.Framework
- AppKitAnimation.Framework
@mbrandonw
Copy link
Member

Hi @maximkrouk, thanks for taking the time to PR. Can you provide a concrete example of how you want to make use of UITransaction.current?

@maximkrouk
Copy link
Author

Can you provide a concrete example of how you want to make use of UITransaction.current?

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
Copy link
Author

Here is the result btw, setting up animation 3 modules away from the container and with a simple withUIKitAnimation feels like magic even tho I imperatively handle those frames and even have my CubicBezierResolver for custom curves 😅

iPhone SE Simulator Recording Mar 5 2025

@stephencelis
Copy link
Member

stephencelis commented Mar 5, 2025

@maximkrouk Rather than expose UITransaction.current, I think it'd be better if we could leverage the existing presentation APIs. The destination helper in particular seems ripe for enhancement here. It currently requires a view controller for presentation and dismissal, but perhaps it could be generalized so that something like this could work:

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 present and dismiss (or container.show) would be responsible for installing the container in the view hierarchy.

As for passing animation data along, how would you want to implement CADisplayLinkAnimator.Animation.init? I think the UIKitAnimation.Framework type is probably not a friendly API to expose, so I'm wondering what kind of work actually happens here. Can you share what this type looks like? Is it in another open source project?

If the animation is better modeled as its own data type, you could instead pass a custom UITransactionKey along, which might be better than trying to switch over the currently internal Framework type.

@maximkrouk
Copy link
Author

maximkrouk commented Mar 5, 2025

show(_:animated) won't solve the problem of not being able to extract animation params, but smth like show(_:transaction) is not very call-site friendly, at least imo 😅

withUIKitAnimation already animates stuff implicitly, but it won't work for stuff that is not animated with UIView.animate and I think there should be a way to have a peek into implementation details to be able to extend this outside of the library

how would you want to implement CADisplayLinkAnimator.Animation.init

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 dump(_:) function, complete snippet would be too big to include in a comment, but I just check for all of these keys in the dump and extract values (currently it doesn't support all animations and spring curves for example are replaced with linear since I don't collect spring params and didn't write a resolver for spring easing)

enum Keys: String, CaseIterable {
  case delay
  case duration
  case speed
  
  // bezier curve params for SwiftUI.Animation
  case ax, bx, cx, ay, by, cy
}

you could instead pass a custom UITransactionKey along

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 components(separatedBy) and trimmingCharacters(in:) 😅), have to do it for SwiftUI anyway. But the main issue is lack of access to current transaction, so it would affect component APIs even if I had a separate UITransactionKey (however there is already withUIKitAnimation api that works with it's own transaction keys, and I'd like to keep animations synced. In my case state change triggers layout update in container's parent, but container only animates blur & opacity of it's contents, but all animations are in sync because I extract params and mimic easing). Also, there may be other cases when access to current transaction might be helpful that we don't know 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
  }
}

Is it in another open source project?

If it turn out to be something cool and ergonomic I'll opensource some stuff like CADisplayLinkAnimator, but it's just a local draft for now. Today I extracted ExistentialContainer for opening existentials into a separate package for example, not super useful, but just coool, probably gonna opensource it this week 😎

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
  }
}

@maximkrouk
Copy link
Author

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)
  }
}

@stephencelis
Copy link
Member

stephencelis commented Mar 5, 2025

show(_:animated) won't solve the problem of not being able to extract animation params, but smth like show(_:transaction) is not very call-site friendly, at least imo 😅

As I mentioned before, you could traffic your custom animation type through a custom UITransactionKey, instead, and it would probably be more ergonomic than attempting to exhaustively convert our internal type:

} present: { container, transaction in
  container.show(
    true,
    animation: transaction.displayLinkAnimation  // You define this
  )
} dismiss: { container, transaction in

withUIKitAnimation already animates stuff implicitly, but it won't work for stuff that is not animated with UIView.animate and I think there should be a way to have a peek into implementation details to be able to extend this outside of the library

Yeah, withUIKitAnimation was built only to solve this very specific use case, and the internal animation type is kind of hamstrung as a result, and we don't think it should be used outside of this. If anything we think that other animation paradigms should introduce their own withMyCustomAnimation functions, and traffic things through custom UITransactionKeys.

the main issue is lack of access to current transaction, so it would affect component APIs even if I had a separate UITransactionKey

My suggestion was that the destination helper could be used to get access to the current transaction. Does this sound like a potential solution that could be explored? Is there any reason not to fully embrace the state-driven approach provided by UIBinding?

(however there is already withUIKitAnimation api that works with it's own transaction keys, and I'd like to keep animations synced. In my case state change triggers layout update in container's parent, but container only animates blur & opacity of it's contents, but all animations are in sync because I extract params and mimic easing).

Nesting these animation functions seems like it should work just fine and be more flexible, the same way that AttributedString has separate UIKit and SwiftUI keys for presentation. Please also note that withUIKitAnimation doesn't automatically introduce an equivalent SwiftUI withAnimation. We want to allow folks to target animations as granularly as the paradigms they work with allow.

Also, there may be other cases when access to current transaction might be helpful that we don't know of 💁‍♂️

While this is theoretically true, there are plenty of times when accessing the UITransaction.current doesn't make sense, and providing global access to it may just lead to misuse. SwiftUI doesn't provide Transaction.current, either, and I do think that scoping its availability makes sense: it requires you to access it in a scope in which it may be available. I think the first avenue to explore is if things can be achieved with the existing scoped helpers, and if not, can we introduce additional scoped helpers to do so.

@stephencelis
Copy link
Member

If it turn out to be something cool and ergonomic I'll opensource some stuff like CADisplayLinkAnimator, but it's just a local draft for now. Today I extracted ExistentialContainer for opening existentials into a separate package for example, not super useful, but just coool, probably gonna opensource it this week 😎

Keep us posted 😄 Especially if you ship a CADisplayLinkAnimatorSwiftNavigation support module!

@maximkrouk
Copy link
Author

maximkrouk commented Mar 5, 2025

A custom UITransactionKey would probably be more ergonomic than attempting to exhaustively convert our internal type

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
This could be improved with a wrapper function, however I really want my component to behave as it was animated with UIView.animate, I'm not sure if I can extract animation params from it, probably not, but transaction from this lib looks like the closest thing to the solution I want 😞

Destination helper could be used to get access to the current transaction

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 observe { transaction in ... })

Is there any reason not to fully embrace the state-driven approach provided by UIBinding

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 💁‍♂️

Please also note that withUIKitAnimation doesn't automatically introduce an equivalent SwiftUI withAnimation

Yes, but it's 2 different frameworks and UIKit has smth like UIView.animate(SUIAnimation), in my case I'm operating in UIKit even tho I'm tracking time and handling animation progress myself...

SwiftUI doesn't provide Transaction.current

It may provide it with some @_spi we're not aware of :D
And SwiftUI is Apple's private framework, I think opensource software should be more open, and spi feels like a perfect way
a) to keep things clean for general audience
b) provide a way to peek into implementation details for risky enthusiasts :D
Is there any specific reasons not to expose it this way?


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
  }
}

😅

@stephencelis
Copy link
Member

stephencelis commented Mar 5, 2025

Convertion is hacky, but it's implementation detail, I think it is worth it if the call site is more ergonomic

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.

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 observe { transaction in ... })

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:

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 💁‍♂️

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 @Environment(\.dismiss).

Is there any specific reasons not to expose it this way?

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 UITransaction.current inside a random Task or TCA Effect, for example. It just wouldn't make sense.

My final solution for now is

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 😅

@maximkrouk
Copy link
Author

maximkrouk commented Mar 5, 2025

Please reread my comment. The example does not pass the transaction in from the outside, but merely the animation data trafficked through 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 💁‍♂️

A binding is also an observable source of truth

Fair, my thoughts were corrupted by SUI where there observation is kinda automatic and Binding is generally used only for two-way bindings, maybe I'll consider it indeed, in case my solution breaks 🌚

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 😅

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.

One shouldn't reach for UITransaction.current inside a random Task or TCA Effect, for example. It just wouldn't make sense.

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:

  • I'll move on with other stuff I have to do for the app I'm working on, I'm kinda ok for now with the current solution I implemented locally
  • I'm experimenting with core UI module api design, to make it more ergonomic, so maybe I'll just figure out that bindings are just better, depends on how current approach will work
  • I would still prefer to have an @_spi instead of my solution with LockIsolated and observe. I agree that it shouldn't be a part of public API to avoid potential misuse, but still makes sense for me since it'll be hidden from the public API. However I found a workaround and don't need the change.

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 💁‍♂️

P.S. Love your libs even when they don't treat users as adults to the degree I'd personally like them to 😅. You guys do amazing stuff that Apple should've provided out of the box, your contribution to the ecosystem is invaluable ❤️


Update/Edit: Just saw one of the recent PRs with adding Animation.Framework to public API, maybe the fact that it wasn't needed also contributes to the decision on this one, there is a difference (public API / spi), however there is a chance the guy could ended up just using private API instead of modifying the code if it was exposed with spi, but imo it still wouldn't be library's responsibility

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants