Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7af62d5
WIP
ephemer Sep 10, 2025
e79526f
fix zIndex
michaelknoch Sep 19, 2025
386da87
Update to Swift 6.2
ephemer Sep 26, 2025
7de952f
Use Texture Views
ephemer Sep 26, 2025
fb83da8
Updates for VideoJNI animations
ephemer Sep 30, 2025
075ea96
Add cornerRadius and some other APIs
ephemer Oct 1, 2025
0682578
Fix bug in Mac Video rendering
ephemer Oct 1, 2025
e944e76
Clean up
ephemer Oct 1, 2025
6755211
Clean up
ephemer Oct 1, 2025
df0dfdd
Update SDL to fix grayscale+alpha image issue
ephemer Oct 1, 2025
34ab8b6
Use 2-channel textures on Android to save 50% GPU memory
ephemer Oct 1, 2025
837ae6d
Don't ship linker stubs (fixes page alignment issues)
ephemer Oct 1, 2025
c26d2a0
Minor cleanup
ephemer Oct 2, 2025
3627800
Fix Android release build compiler crash by simplifiying UIApplicatio…
ephemer Oct 2, 2025
44660d0
update android versions
michaelknoch Oct 2, 2025
527c1f1
Remove unneeded orientation checks
ephemer Oct 13, 2025
7144965
minor cleanup of videoJNI, get rid of some warnings
michaelknoch Oct 14, 2025
7bb7a2e
Consume swift-jni via SwiftPM rather than via a submodule
ephemer Oct 16, 2025
634d8ef
Multiple videos: fix scaling, improve error recovery (#407)
michaelknoch Oct 21, 2025
faade08
don't scale if aspect ratio is the same
michaelknoch Oct 21, 2025
d77c6fb
fix scaling behaviour on tablets
michaelknoch Oct 21, 2025
84ad4b5
simplify inset handling
michaelknoch Oct 24, 2025
810987b
cleanup
michaelknoch Oct 24, 2025
111473b
Update minimum Swift tools version, update SDL_ttf for C++ compat
ephemer Nov 13, 2025
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
9 changes: 5 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
// swift-tools-version:5.7
// swift-tools-version:6.0
import PackageDescription

let package = Package(
name: "UIKit",
platforms: [.macOS(.v10_15)],
platforms: [.macOS(.v13)],
products: [
.library(name: "UIKit", targets: ["UIKit"])
],
dependencies: [
.package(path: "./swift-jni"),
.package(url: "https://github.com/SwiftAndroid/swift-jni", branch: "devel"),
.package(path: "./SDL"),
],
targets: [
Expand All @@ -23,5 +23,6 @@ let package = Package(
exclude: ["Mac-Info.plist"]
),
.target(name: "UIKit_C_API", path: "UIKit_C_API"),
]
],
swiftLanguageModes: [.v5]
)
2 changes: 1 addition & 1 deletion SDL
10 changes: 1 addition & 9 deletions Sources/AVPlayer+Android.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
#if os(Android)
//
// JNIVideo.swift
// UIKit
//
// Created by Chris on 13.09.17.
// Copyright © 2017 flowkey. All rights reserved.
//

import JNI

public class AVPlayer: JNIObject {
public final class AVPlayer: JNIObject {
public override static var className: String { "org.uikit.AVPlayer" }

public var onError: ((ExoPlaybackError) -> Void)?
Expand Down
101 changes: 76 additions & 25 deletions Sources/AVPlayerLayer+Android.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
#if os(Android)
//
// JNIVideo.swift
// UIKit
//
// Created by Chris on 13.09.17.
// Copyright © 2017 flowkey. All rights reserved.
//

import JNI

public enum AVLayerVideoGravity: JavaInt {
Expand All @@ -16,32 +8,91 @@ public enum AVLayerVideoGravity: JavaInt {
}

@MainActor
public class AVPlayerLayer: JNIObject {
final public class AVPlayerLayer: CALayer {
public var kotlinAVPlayerLayer: KotlinAVPlayerLayer?

public convenience init(player: AVPlayer) {
self.init()
kotlinAVPlayerLayer = KotlinAVPlayerLayer(player: player)
}

public var videoGravity: AVLayerVideoGravity = .resizeAspect {
didSet { kotlinAVPlayerLayer?.setVideoGravity(videoGravity) }
}

override public var opacity: Float {
didSet { kotlinAVPlayerLayer?.setAlpha(opacity) }
}

override public var isHidden: Bool {
didSet { kotlinAVPlayerLayer?.setIsHidden(isHidden) }
}

override public func copy() -> AVPlayerLayer {
let copy = super.copy()
// Allow the presentation layer's frame to be animated:
copy.kotlinAVPlayerLayer = kotlinAVPlayerLayer
return copy
}

override public var cornerRadius: CGFloat {
didSet { kotlinAVPlayerLayer?.setCornerRadius(Float(cornerRadius)) }
}

override public var zPosition: CGFloat {
didSet { kotlinAVPlayerLayer?.setElevation(zPosition) }
}

// [Frame Animations]
// `frame` is a computed property, so `position` and `bounds` are what actually gets animated
override public var bounds: CGRect {
didSet { kotlinAVPlayerLayer?.setFrame(frame) }
}

override public var position: CGPoint {
didSet { kotlinAVPlayerLayer?.setFrame(frame) }
}
// [/Frame Animations]
}

@MainActor
public final class KotlinAVPlayerLayer: JNIObject {
override public static var className: String { "org.uikit.AVPlayerLayer" }

public convenience init(player: AVPlayer) {
let parentView = JavaSDLView(getSDLView())
try! self.init(arguments: parentView, player)
}

public var videoGravity: AVLayerVideoGravity = .resizeAspect {
didSet {
try! call("setResizeMode", arguments: [videoGravity.rawValue])
}
public func setVideoGravity(_ newValue: AVLayerVideoGravity) {
// Not implemented because we no longer user ExoPlayer's PlayerView
}

public var frame: CGRect {
get { return .zero } // FIXME: This would require returning a JavaObject with the various params
set {
guard let scale = UIScreen.main?.scale else { return }
let scaledFrame = (newValue * scale)
try! call("setFrame", arguments: [
JavaInt(scaledFrame.origin.x.rounded()),
JavaInt(scaledFrame.origin.y.rounded()),
JavaInt(scaledFrame.size.width.rounded()),
JavaInt(scaledFrame.size.height.rounded())
])
}
public func setAlpha(_ newValue: Float) {
try! call("setAlpha", arguments: [newValue])
}

public func setFrame(_ newValue: CGRect) {
guard let scale = UIScreen.main?.scale else { return }
let scaledFrame = newValue * scale
try! call("setFrame", arguments: [
JavaInt(scaledFrame.origin.x.rounded()),
JavaInt(scaledFrame.origin.y.rounded()),
JavaInt(scaledFrame.size.width.rounded()),
JavaInt(scaledFrame.size.height.rounded())
])
}

public func setCornerRadius(_ newValue: Float) {
try! call("setCornerRadius", arguments: [newValue])
}

public func setIsHidden(_ newValue: Bool) {
try! call("setIsHidden", arguments: [newValue])
}

public func setElevation(_ newValue: Double) {
try! call("setElevation", arguments: [Float(newValue)])
}

deinit {
Expand Down
10 changes: 5 additions & 5 deletions Sources/AVPlayerLayer+Mac.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,12 @@ public final class AVPlayerLayer: CALayer {
player?.currentItem?.remove(playerOutput)

let aspectRatio = presentationSize.width / presentationSize.height
let width = round(size.width)

let width = (size.width * self.contentsScale).rounded()
let widthAlignedTo4PixelPadding = (width.remainder(dividingBy: 8) == 0) ?
width : // <-- no padding required
width + (8 - width.remainder(dividingBy: 8))
width + (8 - width.remainder(dividingBy: 8).magnitude)


playerOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferOpenGLCompatibilityKey as String: true,
Expand All @@ -89,6 +88,7 @@ public final class AVPlayerLayer: CALayer {
currentPlayerOutputSize = size
}

@_optimize(speed)
func updateVideoFrame() {
updatePlayerOutput(size: frame.size)
guard
Expand Down Expand Up @@ -124,4 +124,4 @@ public final class AVPlayerLayer: CALayer {
contents?.replacePixels(with: pixelBytes, bytesPerPixel: 4)
}
}
#endif
#endif // os(macOS)
10 changes: 1 addition & 9 deletions Sources/AVURLAsset+Android.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
#if os(Android)
//
// AVPlayerItem+Android.swift
// UIKit
//
// Created by Geordie Jay on 24.05.17.
// Copyright © 2017 flowkey. All rights reserved.
//

import JNI

public class AVPlayerItem {
Expand All @@ -16,7 +8,7 @@ public class AVPlayerItem {
}
}

public class AVURLAsset: JNIObject {
public final class AVURLAsset: JNIObject {
public override static var className: String { "org.uikit.AVURLAsset" }

@MainActor
Expand Down
4 changes: 2 additions & 2 deletions Sources/CALayer+SDL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ extension CALayer {
// If a mask exists, take it into account when rendering by combining absoluteFrame with the mask's frame
if let mask = mask {
// XXX: we're probably not doing exactly what iOS does if there is a transform on here somewhere
let maskFrame = (mask._presentation ?? mask).frame
let maskFrame = (mask.presentation() ?? mask).frame
let maskAbsoluteFrame = maskFrame.offsetBy(absoluteFrame.origin)

// Don't intersect with previousClippingRect: in a case where both `masksToBounds` and `mask` are
Expand Down Expand Up @@ -143,7 +143,7 @@ extension CALayer {
transformAtSelfOrigin.setAsSDLgpuMatrix()

for sublayer in sublayers {
(sublayer._presentation ?? sublayer).sdlRender(parentAbsoluteOpacity: opacity)
(sublayer.presentation() ?? sublayer).sdlRender(parentAbsoluteOpacity: opacity)
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/CALayer+animations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ extension CALayer {

// animation.fromValue is optional, set it to currently visible state if nil
if copy.fromValue == nil, let keyPath = copy.keyPath {
copy.fromValue = (_presentation ?? self).value(forKeyPath: keyPath)
copy.fromValue = (presentation() ?? self).value(forKeyPath: keyPath)
}

copy.animationGroup?.queuedAnimations += 1
Expand Down
23 changes: 19 additions & 4 deletions Sources/CALayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ open class CALayer {
public required init() {}

public required init(layer: Any) {
guard let layer = layer as? CALayer else { fatalError() }
guard let layer = layer as? CALayer else {
fatalError("Copy of CALayer must be initialized from another CALayer")
}

bounds = layer.bounds
delegate = layer.delegate
transform = layer.transform
Expand All @@ -208,8 +211,8 @@ open class CALayer {
contentsGravity = layer.contentsGravity
}

open func copy() -> Any {
return CALayer(layer: self)
open func copy() -> Self {
return Self(layer: self)
}

open func action(forKey event: String) -> CAAction? {
Expand All @@ -221,7 +224,11 @@ open class CALayer {

/// returns a non animating copy of the layer
func createPresentation() -> CALayer {
let copy = CALayer(layer: self)
// XXX: Should we just return _presentation if it already exists??
// This seems to break animations, but why?
// if let _presentation { return _presentation }
Comment on lines +227 to +229
Copy link
Member

Choose a reason for hiding this comment

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

@michaelknoch this is kind of an open question to you I think. We don't need to answer it right now but I it did confuse me that we create a new presentation layer on every frame – is that how iOS works?


let copy = self.copy()
copy.isPresentationForAnotherLayer = true
return copy
}
Expand All @@ -235,6 +242,14 @@ open class CALayer {
didSet { onDidSetAnimations(wasEmpty: oldValue.isEmpty) }
}

open func animationKeys() -> [String]? {
return animations.keys.isEmpty ? nil : animations.keys.map { $0 }
}

open func animation(forKey key: String) -> CABasicAnimation? {
return animations[key]
}

/// We disable animation on parameters of views / layers that haven't been rendered yet.
/// This is both a performance optimization (avoids lots of animations at the start)
/// as well as a correctness fix (matches iOS behaviour). Maybe there's a better way though?
Expand Down
32 changes: 27 additions & 5 deletions Sources/CGImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,34 @@ public class CGImage {
var data = sourceData

guard let gpuImagePtr = data.withUnsafeMutableBytes({ buffer -> UnsafeMutablePointer<GPU_Image>? in
guard let ptr = buffer.baseAddress?.assumingMemoryBound(to: Int8.self) else {
return nil
var width: Int32 = 0
var height: Int32 = 0
var channels: Int32 = 4

#if os(Android)
// Android natively supports 2-channel textures. Use them to save 50% (GPU) RAM.
let data = stbi_load_from_memory(buffer.baseAddress, Int32(buffer.count), &width, &height, &channels, 0)

let format: GPU_FormatEnum = switch channels {
case 1: GPU_FORMAT_ALPHA
case 2: GPU_FORMAT_LUMINANCE_ALPHA
Copy link
Member

@ephemer ephemer Oct 10, 2025

Choose a reason for hiding this comment

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

@michaelknoch FYI in theory GPU_FORMAT_LUMINANCE_ALPHA (and GPU_FORMAT_ALPHA) is not supported in OpenGLES in versions 3+. So this "should" not work on newer devices. In practice I saw that Android emulates OpenGLES2 in these cases, so it works. I don't think it's problematic, but I wanted to mention it.

I didn't immediately find a way to see if this device is running OpenGLES2 vs OpenGLES3 – if we can find that we could also fall back to using 4-channel textures in that case. But let's see if it's actually a problem first

case 3: GPU_FORMAT_RGB
case 4: GPU_FORMAT_RGBA
default: fatalError()
}

let rw = SDL_RWFromMem(ptr, Int32(buffer.count))
return GPU_LoadImage_RW(rw, true)
#elseif os(macOS)
Copy link
Member

Choose a reason for hiding this comment

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

i'm not sure we need elseif os(macOS) here – it should be just "else"

// OpenGL on macOS does not natively support 2-channel textures (`unit 0 GLD_TEXTURE_INDEX_2D is unloadable`).
// Instead, force `stb_image` to load all images as if they had 4 channels.
// This is more compatible, but requires more memory.
let data = stbi_load_from_memory(buffer.baseAddress, Int32(buffer.count), &width, &height, nil, channels)
let format = GPU_FORMAT_RGBA
#endif

let img = GPU_CreateImage(UInt16(width), UInt16(height), format)
GPU_UpdateImageBytes(img, nil, data, width * channels)
data?.deallocate()

return img
}) else { return nil }

self.init(gpuImagePtr, sourceData: data)
Expand Down
15 changes: 5 additions & 10 deletions Sources/UIApplicationDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,13 @@ public extension UIApplicationDelegate {
func applicationWillResignActive(_ application: UIApplication) {}
func applicationDidEnterBackground(_ application: UIApplication) {}

// Note: this is not used on Android, because there we have a library, so no `main` function will be called.
@MainActor
static func main() async throws {
#if os(macOS)
// On Mac (like on iOS), the main thread blocks here via RunLoop.current.run().
defer { setupRenderAndRunLoop() }
#else
// Android is handled differently: we don't want to block the main thread because the system needs it.
// Instead, we call render periodically from Kotlin via the Android Choreographer API (see UIApplication).
// That said, this function won't even be called on platforms like Android where the app is built as a library, not an executable.
#endif

#if !os(Android) // Unused on Android: we build a library, so no `main` function gets called.
_ = UIApplicationMain(UIApplication.self, Self.self)

// On Mac (like on iOS), the main thread blocks here via RunLoop.current.run().
setupRenderAndRunLoop()
#endif // !os(Android)
}
}
2 changes: 1 addition & 1 deletion Sources/UIScrollView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ open class UIScrollView: UIView {
/// The contentOffset that is currently shown on the screen
/// We won't need this once we implement animations via DisplayLink instead of with UIView.animate
var visibleContentOffset: CGPoint {
return (layer._presentation ?? layer).bounds.origin
return (layer.presentation() ?? layer).bounds.origin
}

/// prevent `newContentOffset` being out of bounds
Expand Down
2 changes: 1 addition & 1 deletion Sources/UIView+SDL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ internal import SDL

extension UIView {
final func sdlDrawAndLayoutTreeIfNeeded(parentAlpha: CGFloat = 1.0) {
let visibleLayer = (layer._presentation ?? layer)
let visibleLayer = (layer.presentation() ?? layer)

let alpha = CGFloat(visibleLayer.opacity) * parentAlpha
if visibleLayer.isHidden || alpha < 0.01 { return }
Expand Down
2 changes: 1 addition & 1 deletion Sources/UIView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ open class UIView: UIResponder, CALayerDelegate, UIAccessibilityIdentification {

let keyPath = AnimationKeyPath(stringLiteral: event)
let beginFromCurrentState = prototype.animationGroup.options.contains(.beginFromCurrentState)
let state = beginFromCurrentState ? (layer._presentation ?? layer) : layer
let state = beginFromCurrentState ? (layer.presentation() ?? layer) : layer

if let fromValue = state.value(forKeyPath: keyPath) {
return prototype.createAnimation(keyPath: keyPath, fromValue: fromValue)
Expand Down
Loading