Skip to content

Bring gestures, animations and rendering closer to UIKit on iOS - #443

Merged
michaelknoch merged 23 commits into
masterfrom
player-minimal
Oct 29, 2026
Merged

michaelknoch merged 23 commits into
masterfrom
player-minimal

Conversation

@michaelknoch

@michaelknoch michaelknoch commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Type of change: Feature, bug fixes

Motivation (current vs expected behavior)

Brings gesture recognition, animations and layer rendering closer to UIKit on iOS, and adds APIs that were missing.

  • Gestures follow iOS's state machine: multiple touches, require(toFail:), long press, gestureRecognizerShouldBegin, scroll view deceleration callbacks
  • New APIs: CAShapeLayer with dashed line paths, UIBezierPath lines, UIView.window and rect conversion (nil is the window), willMove(toSuperview:), UIEvent.touches(for:), location(ofTouch:in:), UILabel.intrinsicContentSize, transform, rect and inset helpers, UIColor(white:alpha:), UIImage.withTintColor, CATransaction timing functions, CALayer.maskedCorners, NSNumber on Android
  • UIView.center is the layer position
  • A nested UIView.animate restores the outer animation
  • contentsCenter draws contents as a nine-slice
  • Tinted images are rebuilt from PNG data, so they come back after Android loses the GL context
  • allowsGroupOpacity, on by default as on iOS, composites a translucent subtree once, so overlapping sublayers don't show through each other

Please check if the PR fulfills these requirements

  • Self-review: I am confident this is the simplest and clearest way to achieve the expected behaviour
  • There are no dependencies on other PRs or I have linked dependencies through Zenhub
  • The commit messages are clean and understandable
  • Tests for the changes have been added (for bug fixes / features)

@michaelknoch
michaelknoch requested a review from ephemer as a code owner October 27, 2026 15:39
@michaelknoch michaelknoch changed the title Support the new NativePlayer on Android: iOS gesture recognition, the APIs it uses, and the rendering fixes it needs Bring gestures, animations and rendering closer to UIKit on iOS Oct 27, 2026
…leration callbacks

- Recognizers get touches before the view, send their action on began, every changed, ended and cancelled, and keep their final state until the touch sequence ends
- A recognizer that recognizes cancels the view's touches with touchesCancelled, also when it recognizes as the touch ends
- require(toFail:), shouldReceive, gestureRecognizerShouldBegin and simultaneous recognition behave as on iOS
- Pans begin after 10pt with a velocity averaged over the last tenth of a second; taps fail when they move or lift with the wrong number of fingers
- Add UILongPressGestureRecognizer; UIPinchGestureRecognizer scales from where the fingers started
- UIScrollView calls scrollViewDidScroll on every frame of a deceleration and scrollViewDidEndDecelerating when it finishes, its pan stops a deceleration on touch down, and its delegate methods are optional
CAShapeLayer with dashed line paths, UIBezierPath lines, UIView.window, rect conversion with nil as the window, willMove(toSuperview:), UIEvent.touches(for:), location(ofTouch:in:), UILabel.intrinsicContentSize, transform, rect and inset helpers, UIColor(white:alpha:), UIImage.withTintColor, CATransaction timing functions, UIScreen.displayCornerRadius on Android, and @preconcurrency on UIView, UIImage, UIFont and UIScreen
- UIView.center is the layer position
- A nested UIView.animate restores the outer animation
- contentsCenter draws contents as a nine-slice
- Tinted images are rebuilt from PNG data, so they come back after Android loses the GL context
- Opt-in allowsGroupOpacity composites a translucent subtree once, so overlapping sublayers don't show through each other
Comment thread Sources/Button.swift Outdated
Comment thread Sources/CALayer+SDL.swift Outdated
Comment thread Sources/CALayer.swift Outdated
Comment thread Sources/CALayer.swift Outdated
Comment thread Sources/CALayer.swift Outdated

public var masksToBounds = false

/// Off by default, unlike iOS: compositing a fading subtree as one costs an offscreen pass per frame.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

but why? we are trying to make our port behave the same as on iOS, so why change the default? if we need to change this for performance reasons we should do it in app code. but even better: we should fix the performance issue

Comment thread Sources/CALayer.swift Outdated
Comment on lines +237 to +238
/// Where subclasses draw what they add to `contents`, in this layer's coordinates offset by `origin`.
func sdlRenderContent(at origin: CGPoint, opacity: Float, renderer: UIScreen) {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

?

to me, the comment doesn't actually explain what this is for. doesn't seem like this would exist in the original API even internally, so do we really need it? why do we want subclasses of this?

Comment thread Sources/CALayer.swift
/// returns a non animating copy of the layer
func createPresentation() -> CALayer {
let copy = CALayer(layer: self)
let copy = self.copy() as! CALayer

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should this be as! Self?

Comment thread Sources/CALayer.swift
@@ -230,7 +246,7 @@ open class CALayer {

/// returns a non animating copy of the layer
func createPresentation() -> CALayer {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should this be -> Self actually?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's -> Any on iOS (NSObject.copy()), so I'd prefer to keep it the same here.

Comment thread Sources/UIScreen.swift Outdated
Comment on lines +243 to +249
extension UIScreen {
/// Android only: the radius of the display's rounded corners, which iOS has no public API for.
public var displayCornerRadius: CGFloat {
let radius: JavaFloat = (try? jni.call("getDisplayCornerRadius", on: getSDLView())) ?? 0
return CGFloat(radius)
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

move this to player, its android only API

super.init(layer: layer)
}

open override func copy() -> Any {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i think the original API truly does return Any but I don't think anyone will be mad if we make this -> Self instead, which would be my slight preference

Comment thread Sources/CGRect.swift Outdated

extension CGRect {
/// The smallest rect containing all the points, or `.zero` without any.
init<Points: Sequence<CGPoint>>(boundingPoints points: Points) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is a kind of unusual way to write this. the modern way would be init(boundingPoints points: some Sequence<CGPoint>) I think – should be 100% equivalent but easier to read and reason about?

Comment thread Sources/Foundation+Android.swift Outdated
@@ -0,0 +1,17 @@
#if os(Android)
public final class NSNumber: ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

hmm ok I don't love that we've half implemented this API and that we'd convert Ints to Double. At the extremes this would lose precision in ways that are hard to reason about. I think NSNumber's storage should probably be modelled as an internal enum

public final class NSNumber ... {

    internal enum Storage {
        case double(Double), int(Int), float(Float)
    }

}

?

I wouldn't block the PR because of this but I think it might be preferable to not lose numerical precision on large/small ints here.

Also, I think NSNumber really is a class on Apple platforms, but that has potentially dramatic performance downsides. Especially since this is a final class with immutable storage, I would consider writing this as a struct instead – semantically it should be the same, without the potential ref counting and heap allocation footguns

Comment thread Sources/UIFont.swift Outdated
@@ -1,4 +1,4 @@
@MainActor
@preconcurrency @MainActor

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this needs some explanation I think?

case cancelled
case failed

public static let recognized = UIGestureRecognizerState.ended

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

?


public weak var delegate: UIGestureRecognizerDelegate?
internal weak var trackedTouch: UITouch?
public internal(set) weak var view: UIView?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

elsewhere (above) we stopped setting view on the gesture recognizer. not sure if that was intentional or correct?

Comment thread Sources/UIGestureRecognizer.swift Outdated
if isSequenceOver {
resetToPossible()
} else {
UIGestureRecognizer.awaitingReset.append(self)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this reads strangely (because it sounds like it's a single recognizer awaiting reset, whereas this is clearly an array or other collection).

Comment thread Sources/UIImage.swift Outdated
#endif

@MainActor // UIImage is MainActor but CGImage is not
@preconcurrency @MainActor // UIImage is MainActor but CGImage is not

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this also needs a comment I think?

Comment thread Sources/UIScreen.swift Outdated
}

@MainActor
@preconcurrency @MainActor

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

?

Comment thread Sources/UIScreen.swift Outdated
}

extension UIScreen {
/// Android only: the radius of the display's rounded corners, which iOS has no public API for.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

so let's write this #if os(Android)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

i will remove this API entirely from here and add it to our app instead. It does not belong here.

Comment thread Sources/UITouch.swift Outdated
private var delayedViewPhases: [UITouchPhase] = []

/// Hands the touch's current phase to its view, unless a recognizer cancelled it or delays it.
func deliverToView() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this API sounds misleading. it should be something like maybeDeliverToView?

@ephemer ephemer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall it looks good, would be great if you could address/fix the issues raised here wherever possible before merge. Thanks for working on this!

@michaelknoch

Copy link
Copy Markdown
Member Author

Thanks for the great feedback @ephemer ! I will clean this up before merge

@michaelknoch
michaelknoch merged commit bdda9d8 into master Oct 29, 2026
1 check passed
@michaelknoch
michaelknoch deleted the player-minimal branch October 29, 2026 10:33
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.

2 participants