Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion SDL
Submodule SDL updated 2 files
+2 −1 Package.swift
+1 −1 external/sdl-gpu
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
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'

android {
compileSdkVersion 34
compileSdkVersion 35

defaultConfig {
minSdkVersion 21
Expand Down
2 changes: 1 addition & 1 deletion samples/getting-started/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'

android {
compileSdkVersion 34
compileSdkVersion 35
defaultConfig {
applicationId "com.example"
minSdkVersion 24
Expand Down
5 changes: 3 additions & 2 deletions samples/getting-started/android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
ext.kotlin_version = '1.9.22'
ext.kotlin_version = '2.2.20'
ext.agp_version = '8.13.0'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.3.1'
classpath "com.android.tools.build:gradle:$agp_version"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

// NOTE: Do not place your application dependencies here; they belong
Expand Down
Loading