mirror of
https://github.com/cappuccino/cappuccino.git
synced 2026-09-14 14:41:28 +00:00
Merge pull request #3147 from daboe01/new-caanimationgroup
New: CAAnimationGroup and implement timer-based animations in CALayer
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user