Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
75 changes: 75 additions & 0 deletions example/src/demos/SpinDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { EaseView } from 'react-native-ease';

import { Section } from '../components/Section';
import { Button } from '../components/Button';

export function SpinDemo() {
const [playing, setPlaying] = useState(false);
const [playingWithScale, setPlayingWithScale] = useState(false);
return (
<>
<Section title="Spin (Repeat Loop)">
{playing && (
<EaseView
initialAnimate={{ rotate: 0 }}
animate={{ rotate: 360 }}
transition={{
type: 'timing',
duration: 1000,
easing: 'linear',
loop: 'repeat',
}}
style={styles.box}
>
<View style={styles.indicator} />
</EaseView>
)}
<Button
label={playing ? 'Stop' : 'Start'}
onPress={() => setPlaying((p) => !p)}
/>
</Section>
<Section title="Spin + Scale (Reverse Loop)">
{playingWithScale && (
<EaseView
initialAnimate={{ rotate: 0, scale: 0.8 }}
animate={{ rotate: 360, scale: 1.2 }}
transition={{
type: 'timing',
duration: 1500,
easing: 'easeInOut',
loop: 'reverse',
}}
style={styles.box}
>
<View style={styles.indicator} />
</EaseView>
)}
<Button
label={playingWithScale ? 'Stop' : 'Start'}
onPress={() => setPlayingWithScale((p) => !p)}
/>
</Section>
</>
);
}

const styles = StyleSheet.create({
box: {
width: 80,
height: 80,
backgroundColor: '#4a90d9',
borderRadius: 12,
alignItems: 'center',
justifyContent: 'flex-start',
paddingTop: 8,
},
indicator: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#fff',
},
});
2 changes: 2 additions & 0 deletions example/src/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { StyleReRenderDemo } from './StyleReRenderDemo';
import { StyledCardDemo } from './StyledCardDemo';
import { TransformOriginDemo } from './TransformOriginDemo';
import { PerPropertyDemo } from './PerPropertyDemo';
import { SpinDemo } from './SpinDemo';
import { UniwindDemo } from './uniwind/UniwindDemo';

interface DemoEntry {
Expand Down Expand Up @@ -75,6 +76,7 @@ export const demos: Record<string, DemoEntry> = {
title: 'Uniwind',
section: 'Style',
},
'spin': { component: SpinDemo, title: 'Spin', section: 'Loop' },
'pulse': { component: PulseDemo, title: 'Pulse', section: 'Loop' },
'banner': { component: BannerDemo, title: 'Banner', section: 'Loop' },
'interrupt': {
Expand Down
83 changes: 76 additions & 7 deletions ios/EaseView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ - (void)invalidateLayer;
// Animation key constants
static NSString *const kAnimKeyOpacity = @"ease_opacity";
static NSString *const kAnimKeyTransform = @"ease_transform";
static NSString *const kAnimKeyTransformRotateZ = @"ease_transform_rotZ";
static NSString *const kAnimKeyTransformRotateX = @"ease_transform_rotX";
static NSString *const kAnimKeyTransformRotateY = @"ease_transform_rotY";
static NSString *const kAnimKeyTransformScaleX = @"ease_transform_scX";
static NSString *const kAnimKeyTransformScaleY = @"ease_transform_scY";
static NSString *const kAnimKeyTransformTransX = @"ease_transform_trX";
static NSString *const kAnimKeyTransformTransY = @"ease_transform_trY";
static NSString *const kAnimKeyCornerRadius = @"ease_cornerRadius";
static NSString *const kAnimKeyBackgroundColor = @"ease_backgroundColor";

Expand Down Expand Up @@ -373,7 +380,10 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps {
}
}
if (hasInitialTransform) {
// Build mask of which transform sub-properties actually changed
// Build bitmask of which transform sub-properties differ between initial
// and target. Comparing raw prop values (not composed matrices) so that
// e.g. rotate 0→360 is correctly detected as a change even though the
// resulting CATransform3D matrices are identical.
int changedInitTransform = 0;
if (viewProps.initialAnimateTranslateX != viewProps.animateTranslateX)
changedInitTransform |= kMaskTranslateX;
Expand All @@ -395,12 +405,71 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps {
transitionConfigForProperty(transformName, viewProps);
self.layer.transform = targetT;
if (transformConfig.type != "none") {
[self applyAnimationForKeyPath:@"transform"
animationKey:kAnimKeyTransform
fromValue:[NSValue valueWithCATransform3D:initialT]
toValue:[NSValue valueWithCATransform3D:targetT]
config:transformConfig
loop:YES];
// Animate each changed sub-property individually using key paths.
// This avoids matrix interpolation which fails for cases like
// rotate 0→360 (identical matrices, but visually a full rotation).
if (changedInitTransform & kMaskTranslateX) {
[self applyAnimationForKeyPath:@"transform.translation.x"
animationKey:kAnimKeyTransformTransX
fromValue:@(viewProps.initialAnimateTranslateX)
toValue:@(viewProps.animateTranslateX)
config:transformConfig
loop:YES];
}
if (changedInitTransform & kMaskTranslateY) {
[self applyAnimationForKeyPath:@"transform.translation.y"
animationKey:kAnimKeyTransformTransY
fromValue:@(viewProps.initialAnimateTranslateY)
toValue:@(viewProps.animateTranslateY)
config:transformConfig
loop:YES];
}
if (changedInitTransform & kMaskScaleX) {
[self applyAnimationForKeyPath:@"transform.scale.x"
animationKey:kAnimKeyTransformScaleX
fromValue:@(viewProps.initialAnimateScaleX)
toValue:@(viewProps.animateScaleX)
config:transformConfig
loop:YES];
}
if (changedInitTransform & kMaskScaleY) {
[self applyAnimationForKeyPath:@"transform.scale.y"
animationKey:kAnimKeyTransformScaleY
fromValue:@(viewProps.initialAnimateScaleY)
toValue:@(viewProps.animateScaleY)
config:transformConfig
loop:YES];
}
if (changedInitTransform & kMaskRotate) {
[self applyAnimationForKeyPath:@"transform.rotation.z"
animationKey:kAnimKeyTransformRotateZ
fromValue:@(degreesToRadians(
viewProps.initialAnimateRotate))
toValue:@(degreesToRadians(
viewProps.animateRotate))
config:transformConfig
loop:YES];
}
if (changedInitTransform & kMaskRotateX) {
[self applyAnimationForKeyPath:@"transform.rotation.x"
animationKey:kAnimKeyTransformRotateX
fromValue:@(degreesToRadians(
viewProps.initialAnimateRotateX))
toValue:@(degreesToRadians(
viewProps.animateRotateX))
config:transformConfig
loop:YES];
}
if (changedInitTransform & kMaskRotateY) {
[self applyAnimationForKeyPath:@"transform.rotation.y"
animationKey:kAnimKeyTransformRotateY
fromValue:@(degreesToRadians(
viewProps.initialAnimateRotateY))
toValue:@(degreesToRadians(
viewProps.animateRotateY))
config:transformConfig
loop:YES];
}
}
}
if (hasInitialBorderRadius) {
Expand Down
49 changes: 49 additions & 0 deletions src/__tests__/EaseView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -624,4 +624,53 @@ describe('EaseView', () => {
expect(t.transform!.stiffness).toBe(100);
});
});

describe('rotate loop props', () => {
it('passes rotate 0→360 with loop repeat to native', () => {
render(
<EaseView
testID="ease"
initialAnimate={{ rotate: 0 }}
animate={{ rotate: 360 }}
transition={{
type: 'timing',
duration: 1000,
easing: 'linear',
loop: 'repeat',
}}
/>,
);
const props = getNativeProps();
expect(props.initialAnimateRotate).toBe(0);
expect(props.animateRotate).toBe(360);
expect(props.transitions.defaultConfig.loop).toBe('repeat');
// rotate bit = 1<<5 = 32
// eslint-disable-next-line no-bitwise
expect(props.animatedProperties & 32).toBe(32);
});

it('passes rotate with scale for combined loop animation', () => {
render(
<EaseView
testID="ease"
initialAnimate={{ rotate: 0, scale: 0.8 }}
animate={{ rotate: 360, scale: 1.2 }}
transition={{
type: 'timing',
duration: 1500,
easing: 'easeInOut',
loop: 'reverse',
}}
/>,
);
const props = getNativeProps();
expect(props.initialAnimateRotate).toBe(0);
expect(props.animateRotate).toBe(360);
expect(props.initialAnimateScaleX).toBe(0.8);
expect(props.initialAnimateScaleY).toBe(0.8);
expect(props.animateScaleX).toBe(1.2);
expect(props.animateScaleY).toBe(1.2);
expect(props.transitions.defaultConfig.loop).toBe('reverse');
});
});
});
Loading