Merge pull request #3147 from daboe01/new-caanimationgroup

New: CAAnimationGroup and implement timer-based animations in CALayer
This commit is contained in:
daboe01
2026-01-25 19:34:34 +01:00
committed by GitHub
3 changed files with 555 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
/*
* CAAnimationGroup.j
* AppKit
* Created by Daniel Boehringer.
* Copyright 2025.
*
* Implements grouping for Core Animation.
*/
@import <Foundation/CPArray.j>
@import "CAAnimation.j"
@implementation CAAnimationGroup : CAAnimation
{
CPArray _animations;
}
+ (id)group
{
return [[self alloc] init];
}
- (id)init
{
if (self = [super init])
{
_animations = [];
}
return self;
}
- (void)setAnimations:(CPArray)anArray
{
if (_animations === anArray)
return;
_animations = anArray;
}
- (CPArray)animations
{
return _animations;
}
/*
Iterates through children and executes them recursively.
This effectively runs all grouped animations concurrently.
*/
- (void)runActionForKey:(CPString)aKey object:(id)anObject arguments:(CPDictionary)arguments
{
var count = [_animations count],
i = 0;
for (; i < count; i++)
{
var animation = [_animations objectAtIndex:i];
// Recursively call runActionForKey on the child.
// If the child is a CABasicAnimation, it will call [anObject addAnimation:...]
// If the child is another Group, it will recurse here.
if ([animation respondsToSelector:@selector(runActionForKey:object:arguments:)])
{
[animation runActionForKey:aKey object:anObject arguments:arguments];
}
}
}
@end
+240
View File
@@ -118,6 +118,8 @@ var CALayerRegisteredRunLoopUpdates = nil;
CGAffineTransform _transformToLayer;
CGAffineTransform _transformFromLayer;
CPMutableDictionary _activeAnimations;
}
@global document
@@ -160,6 +162,8 @@ var CALayerRegisteredRunLoopUpdates = nil;
_sublayers = [];
_activeAnimations = [CPMutableDictionary dictionary];
#if PLATFORM(DOM)
_DOMElement = document.createElement("div");
@@ -977,6 +981,242 @@ if (_DOMContentsElement && aLayer._zPosition > _DOMContentsElement.style.zIndex)
return _delegate;
}
/*
Adds an animation to the layer.
Supports CABasicAnimation for Numbers (opacity) and Points (position/anchorPoint).
*/
- (void)addAnimation:(CAAnimation)anim forKey:(CPString)key
{
if (!anim) return;
// --- 1. Handle Animation Groups ---
// If it's a group, we simply schedule its children individually.
if ([anim respondsToSelector:@selector(animations)] && [anim animations])
{
var animations = [anim animations],
count = [animations count],
i = 0;
for (; i < count; i++)
{
var child = [animations objectAtIndex:i];
// Recurse: Add the child animation.
// We pass 'nil' for the key so the child's own 'keyPath'
// is used as the storage identifier in the dictionary.
[self addAnimation:child forKey:nil];
}
return;
}
// --- 2. Determine KeyPath ---
var keyPath = key;
// If the animation object has an explicit keyPath (like CABasicAnimation), use it.
if ([anim respondsToSelector:@selector(keyPath)] && [anim keyPath])
keyPath = [anim keyPath];
// If we can't determine a property to animate, we must abort.
if (!keyPath) return;
// --- 3. Determine Values ---
var startValue = ([anim respondsToSelector:@selector(fromValue)]) ? [anim fromValue] : nil;
// If startValue is missing, try to read it from the layer.
// We wrap this in a try-catch to prevent crashes if 'keyPath' is invalid.
if (startValue == nil)
{
try {
startValue = [self valueForKey:keyPath];
}
catch (e) {
// The keyPath was likely invalid (not KVC compliant), abort.
return;
}
}
var endValue = ([anim respondsToSelector:@selector(toValue)]) ? [anim toValue] : nil;
if (endValue == nil)
return;
var duration = ([anim respondsToSelector:@selector(duration)]) ? [anim duration] : 0.25;
// Default to EaseInEaseOut if not specified
var timingFunction = ([anim respondsToSelector:@selector(timingFunction)]) ? [anim timingFunction] : [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
// --- 4. Prepare Context ---
var context = {
"animation": anim,
"keyPath": keyPath,
"startValue": startValue,
"endValue": endValue,
"duration": duration * 1000.0, // ms
"timingFunction": timingFunction,
"startTime": null,
"requestId": null
};
// --- 5. Render Loop ---
var _self = self;
var renderLoop = function(timestamp) {
if ([_self _renderAnimationStep:context timestamp:timestamp])
context.requestId = window.requestAnimationFrame(renderLoop);
else
context.requestId = null;
};
// --- 6. Storage & Kickoff ---
// Use the keyPath as the identifier if no specific key was provided
var storageKey = (key && key.length > 0) ? key : keyPath;
// Remove any conflicting animation on this specific property/key
[self removeAnimationForKey:storageKey];
context.requestId = window.requestAnimationFrame(renderLoop);
[_activeAnimations setObject:context forKey:storageKey];
}
- (void)removeAnimationForKey:(CPString)key
{
var context = [_activeAnimations objectForKey:key];
if (context)
{
if (context.requestId !== null)
window.cancelAnimationFrame(context.requestId);
[_activeAnimations removeObjectForKey:key];
}
}
- (void)removeAllAnimations
{
var keys = [_activeAnimations allKeys],
count = [keys count];
while (count--)
[self removeAnimationForKey:[keys objectAtIndex:count]];
}
/*
Solves Cubic Bezier for t.
p1, p2 are the control points (x,y). p0 is 0,0, p3 is 1,1.
This is a simplified solver for standard Core Animation timing functions.
*/
- (float)_solveBezier:(float)t forTimingFunction:(CAMediaTimingFunction)tf
{
if (!tf) return t;
// Linear optimization
if (tf === [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear])
return t;
var points = [tf controlPoints]; // [c1x, c1y, c2x, c2y]
var p1x = points[0], p1y = points[1],
p2x = points[2], p2y = points[3];
// Simple polynomial evaluation (De Casteljau's algorithm/Cubic formula subset)
// Since we are usually dealing with standard easing, we can approximate 1D easing on the Y axis
// based on linear time X, or do a full solve.
// For brevity/speed in JS, we often approximate basic easing:
// 3t^2 * (1-t) + t^3 ... standard bezier blending functions
var cx = 3.0 * p1x;
var bx = 3.0 * (p2x - p1x) - cx;
var ax = 1.0 - cx - bx;
var cy = 3.0 * p1y;
var by = 3.0 * (p2y - p1y) - cy;
var ay = 1.0 - cy - by;
// Solve for X given t (time) using Newton-Raphson
var sampleT = t;
for (var i = 0; i < 5; i++) {
var x = ((ax * sampleT + bx) * sampleT + cx) * sampleT - t;
if (Math.abs(x) < 1e-3) break;
var d = (3.0 * ax * sampleT + 2.0 * bx) * sampleT + cx;
if (Math.abs(d) < 1e-6) break;
sampleT = sampleT - x / d;
}
// Solve for Y given derived T
return ((ay * sampleT + by) * sampleT + cy) * sampleT;
}
- (BOOL)_renderAnimationStep:(JSObject)context timestamp:(double)timestamp
{
if (context.startTime === null)
context.startTime = timestamp;
var elapsed = timestamp - context.startTime,
linearProgress = elapsed / context.duration;
if (linearProgress > 1.0) linearProgress = 1.0;
// Apply Timing Function
var progress = [self _solveBezier:linearProgress forTimingFunction:context.timingFunction];
var start = context.startValue,
end = context.endValue,
current = nil;
// Number
if (typeof start === "number")
{
current = start + (end - start) * progress;
}
// Point / Size / Rect
else if (start && start.x !== undefined && start.y !== undefined) // CGPoint
{
current = CGPointMake(start.x + (end.x - start.x) * progress,
start.y + (end.y - start.y) * progress);
}
else if (start && start.width !== undefined && start.height !== undefined) // CGSize
{
current = CGSizeMake(start.width + (end.width - start.width) * progress,
start.height + (end.height - start.height) * progress);
}
else if (start && start.origin !== undefined && start.size !== undefined) // CGRect
{
current = CGRectMake(
start.origin.x + (end.origin.x - start.origin.x) * progress,
start.origin.y + (end.origin.y - start.origin.y) * progress,
start.size.width + (end.size.width - start.size.width) * progress,
start.size.height + (end.size.height - start.size.height) * progress
);
}
if (current !== nil)
[self setValue:current forKey:context.keyPath];
if (linearProgress >= 1.0)
{
var anim = context.animation;
// Cleanup
var shouldRemove = [anim respondsToSelector:@selector(isRemovedOnCompletion)] ? [anim isRemovedOnCompletion] : YES;
if (shouldRemove) {
// Find key by context identity to handle groups correctly
var keys = [_activeAnimations allKeys];
for (var i = 0; i < keys.length; i++) {
if ([_activeAnimations objectForKey:keys[i]] === context) {
[_activeAnimations removeObjectForKey:keys[i]];
break;
}
}
}
// Delegate
var delegate = [anim delegate];
if (delegate && [delegate respondsToSelector:@selector(animationDidStop:finished:)])
[delegate animationDidStop:anim finished:YES];
return NO;
}
return YES;
}
/* @ignore */
- (void)_setOwningView:(CPView)anOwningView
{
@@ -9,6 +9,7 @@
@import <Foundation/Foundation.j>
@import <AppKit/AppKit.j>
@import <AppKit/CAKeyframeAnimation.j>
@import <AppKit/CAAnimationGroup.j>
#define UIAssert(a) [self markTest:_cmd didPass:a];
@@ -403,6 +404,252 @@
[CPAnimationContext endGrouping];
}
- (void)testGroupAnimation:(id)sender
{
// Reset state
[_testView setFrame:_initialTestViewFrame];
[_testView setAlphaValue:1.0];
[_pathView setPath:nil]; // Clear the path view as we aren't using it here
// FIX: Ensure the view is layer-backed.
// Without this, [_testView layer] returns nil.
[_testView setWantsLayer:YES];
var layer = [_testView layer];
// 1. Define the start and end positions
// CALayer 'position' corresponds to the center of the view (anchorPoint 0.5,0.5)
var startPos = [layer position];
// Safety check in case layer creation failed (though setWantsLayer:YES should ensure it)
if (!startPos) startPos = CGPointMake(0,0);
var endPos = CGPointMake(startPos.x + 150, startPos.y + 50);
// 2. Create a Position Animation
var moveAnim = [CABasicAnimation animationWithKeyPath:@"position"];
[moveAnim setFromValue:startPos];
[moveAnim setToValue:endPos];
[moveAnim setDuration:1.0];
// 3. Create an Opacity Animation
var fadeAnim = [CABasicAnimation animationWithKeyPath:@"opacity"];
[fadeAnim setFromValue:1.0];
[fadeAnim setToValue:0.25];
[fadeAnim setDuration:1.0];
// 4. Group them
// This tests the recursive logic in CAAnimationGroup and the timer logic in CALayer
var group = [CAAnimationGroup group];
[group setAnimations:[moveAnim, fadeAnim]];
[group setDuration:1.0];
// 5. Run the animation on the layer
[layer addAnimation:group forKey:@"groupTest"];
// 6. Verify results after the animation completes (1.0s duration + 0.1s buffer)
[self performSelector:@selector(_verifyGroupAnimation:) withObject:endPos afterDelay:1.1];
}
- (void)_verifyGroupAnimation:(CGPoint)expectedPos
{
var layer = [_testView layer],
currentPos = [layer position],
currentOpacity = [layer opacity];
// Allow for small floating point differences
var posPassed = (Math.abs(currentPos.x - expectedPos.x) < 1.0 && Math.abs(currentPos.y - expectedPos.y) < 1.0);
var opacityPassed = (Math.abs(currentOpacity - 0.25) < 0.05);
[self markTest:@selector(testGroupAnimation:) didPass:(posPassed && opacityPassed)];
// Reset for next test
[self performSelector:@selector(cleanupAfterAnimation) withObject:nil afterDelay:0.5];
}
- (void)testManualRotation:(id)sender
{
// 1. Cleanup previous test view
if (_testView)
[_testView removeFromSuperview];
// 2. Setup the RotatableView
// View is 100x100, but the blue box drawn inside is 70x70 to allow room to spin.
var frame = CGRectMake(390, 450, 100, 100);
_testView = [[RotatableView alloc] initWithFrame:frame];
[[theWindow contentView] addSubview:_testView];
// Ensure layer-backed so we have a layer to animate
[_testView setWantsLayer:YES];
var layer = [_testView layer];
[layer setDelegate:_testView];
// 3. Define the Animation
var rotationAnim = [CABasicAnimation animationWithKeyPath:@"angle"];
// Rotate 360 degrees (2 * PI)
[rotationAnim setFromValue:0.0];
[rotationAnim setToValue:2 * PI];
[rotationAnim setDuration:2.0];
// Use an easing function for smooth start/stop
[rotationAnim setTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]];
// 4. Add to Layer
[layer addAnimation:rotationAnim forKey:@"rotateTest"];
// 5. Verify results after animation
[self performSelector:@selector(_verifyRotation:) withObject:nil afterDelay:2.1];
}
- (void)_verifyRotation:(id)sender
{
var layer = [_testView layer];
var endAngle = [layer angle];
// Check if we reached approx 2*PI (6.28)
var passed = (Math.abs(endAngle - (2 * PI)) < 0.1);
[self markTest:@selector(testManualRotation:) didPass:passed];
// Reset view
[self performSelector:@selector(cleanupAfterAnimation) withObject:nil afterDelay:0.5];
}
@end
/*
A custom view that draws a box with a line in it.
We draw the box smaller than the view bounds to prevent clipping during rotation.
*/
@implementation RotatableView : CPView
{
float _angle;
}
- (void)initWithFrame:(CGRect)aFrame
{
self = [super initWithFrame:aFrame];
_angle = 0;
return self;
}
- (void)setAngle:(float)anAngle
{
_angle = anAngle;
[self display];
}
- (float)angle
{
return _angle;
}
- (void)drawRect:(CGRect)aRect
{
var context = [[CPGraphicsContext currentContext] graphicsPort],
bounds = [self bounds],
cx = CGRectGetWidth(bounds) / 2.0,
cy = CGRectGetHeight(bounds) / 2.0;
// 1. Clear Context
CGContextClearRect(context, bounds);
// 2. Precompute Trig
var cosA = Math.cos(_angle),
sinA = Math.sin(_angle);
/*
Helper closure to transform a local point (x,y) relative to center
into global view coordinates.
*/
var getPoint = function(localX, localY)
{
// Rotation Matrix:
// x' = x*cos - y*sin
// y' = x*sin + y*cos
var rotX = localX * cosA - localY * sinA;
var rotY = localX * sinA + localY * cosA;
// Translate back to view center
return CGPointMake(cx + rotX, cy + rotY);
};
// --- DRAW BLUE SQUARE (70x70) ---
var s = 35.0; // half size
// Calculate the 4 corners manually
var p1 = getPoint(-s, -s); // Top-Left
var p2 = getPoint( s, -s); // Top-Right
var p3 = getPoint( s, s); // Bottom-Right
var p4 = getPoint(-s, s); // Bottom-Left
CGContextBeginPath(context);
CGContextMoveToPoint(context, p1.x, p1.y);
CGContextAddLineToPoint(context, p2.x, p2.y);
CGContextAddLineToPoint(context, p3.x, p3.y);
CGContextAddLineToPoint(context, p4.x, p4.y);
CGContextClosePath(context);
[[CPColor greenColor] setFill];
CGContextFillPath(context);
// --- DRAW RED MARKER (Top-Left Corner) ---
// A 20x20 square in the top-left of the blue box
// Local coords relative to center: x from -35 to -15, y from -35 to -15
var r1 = getPoint(-35, -35);
var r2 = getPoint(-15, -35);
var r3 = getPoint(-15, -15);
var r4 = getPoint(-35, -15);
CGContextBeginPath(context);
CGContextMoveToPoint(context, r1.x, r1.y);
CGContextAddLineToPoint(context, r2.x, r2.y);
CGContextAddLineToPoint(context, r3.x, r3.y);
CGContextAddLineToPoint(context, r4.x, r4.y);
CGContextClosePath(context);
[[CPColor redColor] setFill];
CGContextFillPath(context);
// --- DRAW WHITE POINTER LINE ---
// Line from Center (0,0) to Right Edge (35, 0)
var lineStart = getPoint(0, 0);
var lineEnd = getPoint(35, 0);
CGContextBeginPath(context);
CGContextMoveToPoint(context, lineStart.x, lineStart.y);
CGContextAddLineToPoint(context, lineEnd.x, lineEnd.y);
[[CPColor whiteColor] setStroke];
CGContextSetLineWidth(context, 3.0);
CGContextStrokePath(context);
}
@end
/*
Category to allow CALayer to drive the 'angle' property on the view.
*/
@implementation CALayer (RotationTest)
- (void)setAngle:(float)anAngle
{
// Store it so [self valueForKey:@"angle"] works for animation start values
self._angle = anAngle;
// Forward the value to the View (the layer's delegate) to trigger drawRect
if (_delegate && [_delegate respondsToSelector:@selector(setAngle:)])
[_delegate setAngle:anAngle];
}
- (float)angle
{
return self._angle || 0.0;
}
@end
var unCamelCase = function(aString)