Animation rotate from 360deg to 0deg - iOS sensor

Viewed 463

When I try to animate rotation mapped to an iOS compass, the animation does full circle in opposite direction.

There has been related question about this for CSS3 transforms, suggesting that I should just keep increasing the degrees over 360 and it will just wrap around. Well this technique cannot be used with the sensor data as it just goes from 359, 360, straight to 0. And the interpolation algorithm in React Native just doesn't handle this.

This is my current code:

const degrees = useRef(new Animated.Value(0)).current;

useEffect(() => {
    Animated.timing(
        degrees,
        {
            toValue: location.trueHeading,
            useNativeDriver: true
        }
    ).start();
}, [location.trueHeading])


const renderArrow = () => {
    return (
        <Animated.View
            style={{
                transform: [{
                    rotate: ((degrees.interpolate({
                        inputRange: [0, 360],
                        outputRange: ['0deg', '-360deg']
                    })))
                }]
            }}
        >
            <IconArrow />
        </Animated.View>
    )
}

I do not need specific answer for React Native. General answer how would you solve this issue will suffice.

Thank you

2 Answers

So I am answering my own question after some time to explain how I resolved this issue.

This solution doesn't use React's Animation framework (nor the nativeDriver) but it uses useAnimationFrame callback. So we won't be touching the issue with interpolation method.

Instead I opted for calculating the distance to the desired heading (from a sensor) between rotating left and rotating right by "step" amount. It's called cyclic distance and it's implemented something like this:

function cyclicDistance(current: number, now: number): number {
    const abs = Math.abs(current - now);
    const distanceAround = 360 - abs;
    const distanceStraight = abs;
    const distance = Math.min(distanceAround, distanceStraight);

    return distance;
}

So we are getting the shortest path via Math.min of the two possible solutions.

I am using Reference (useRef Hook) to store current value of heading. Next we are calculating two distances:

  • The first one - base distance - is cyclic distance between current sensor value and our current heading value.

  • The second one is used resolving "tendency" like asking "if we increase our heading by 1 (step) would the cyclic distance increase or decrease?"

  • const baseDistance = cyclicDistance( sensorHeading, myHeadingRef );

    const tendency = cyclicDistance(sensorHeading + 1, myHeadingRef) < baseDistance ? 1 : -1;

The last piece of the puzzle is value wraparound. We are allowing the value of degrees to be higher than 360 or lower than 0. React's CSS rotate transformation can handle that, so we can make use of it.

if (myHeading.current < 0) {
    myHeading.current += 360;
} else if (myHeading.current > 360) {
    myHeading.current -= 360;
}

Putting it all together:

function cyclicDistance(current: number, now: number): number {
    const abs = Math.abs(current - now);
    const distanceAround = 360 - abs;
    const distanceStraight = abs;
    const distance = Math.min(distanceAround, distanceStraight);

    return distance;
}

const [heading, setHeading] = useState(0);
const myHeading = useRef<number>(0);
const locationRef = useRef<Array<number>>([0, 0]);

const step = () => {
    if (myHeading.current < 0) {
        myHeading.current += 360;
    } else if (myHeading.current > 360) {
        myHeading.current -= 360;
    }

    const maxDistance = 10;

    const distance = cyclicDistance(
        myHeading.current,
        locationRef.current[1]
    );

    const tendency =
        cyclicDistance(myHeading.current + 1, locationRef.current[1]) <
        distance
            ? 1
            : -1;

    const move = distance > maxDistance ? maxDistance : distance;

    const newpos = myHeading.current + tendency * move;

    myHeading.current = newpos;
    setHeading(myHeading.current);
};

useAnimationFrame(() => step(), []);

It might need a little bit of refactoring to increase readability, but its good enough for you the get the idea.

Two points to take note of, and one potential way this could work...

First off, you can listen to the value changes and reset it as you want, but this stops any current animations.

degrees.addListener(({ value }) => {
  if (value === 360 || value >= 360) degrees.setValue(value % 360);
});

Secondly, when you are at 360, you can animate to 0 with duration 0 to kind of reset the value but without the pointer going counterclockwise to 0.

Animated.timing(degrees, {
    toValue: 0,
    duration: 0,
    useNativeDriver: true,
}),

Now you can take these into consideration and try to craft a way where you know the next value, and the previous value you can check if there is a boundary-crossing, and break it into parts.

To explain further, say if you have to go from 350 to 30, first go from 350 to 360 then, do a reset with duration 0 from 360 to 0 and then go from 0 to 30.

Related