How can I allow the user to scroll a very large Skia canvas?

438 views Asked by At

I have a React Native app which will include a graph that the user can scroll through. For the sake of this example, I'm just using a horizontal row of circles.

To draw my graph, I am using Skia (https://shopify.github.io/react-native-skia/)

Normally, I would create a Canvas object that is large enough to contain the entire graph, and throw that inside a ScrollView.

<ScrollView horizontal={true}>
    <Canvas style={{ width:5000, height:200 }}>
            <GetCircles />
    </Canvas>
</ScrollView>

The problem is that this canvas needs to be VERY large, since the user will be able to scroll through months of data. Therefore, the width property may need to be so large that the underlying OpenGL texture is created at a size that is too large for the hardware or OpenGL implementation (or so I assume from experience with Skia in other environments such as Xamarin.Forms).

In my tests, setting a width of around 6300 causes the app to immediately crash.

To address this, my thought was to overlay a ScrollView on top of the Skia Canvas. The ScrollView could contain a large View object to match the size that I intend to simulate, and whenever the ScrollView scrolls (its onScroll event is fired), then I would set a state which ultimately results in the Skia Canvas being drawn with a translation, as shown in the following snippet:

const [scrollOffset, setScrollOffset] = useState(0);

function handleScroll(event: NativeSyntheticEvent<NativeScrollEvent>): void {
    setScrollOffset(event.nativeEvent.contentOffset.x);
}
...
<Canvas style={{ width:800, height:200 }}>
    <Group transform={[{translateX:-scrollOffset}]}>
        <GetCircles />
    </Group>
</Canvas>

<ScrollView horizontal={true} style={{height:100}} onScroll={handleScroll}>
    {/* These are added just for the sake of the example: */}
    <View style={{width:150, backgroundColor:"red"}} ></View>
    <View style={{width:150, backgroundColor:"blue"}} ></View>
    <View style={{width:150, backgroundColor:"red"}} ></View>
    <View style={{width:150, backgroundColor:"blue"}} ></View>
    <View style={{width:150, backgroundColor:"red"}} ></View>
    <View style={{width:150, backgroundColor:"blue"}} ></View>
    <View style={{width:150, backgroundColor:"red"}} ></View>
    <View style={{width:150, backgroundColor:"blue"}} ></View>
</ScrollView>

This approach works in terms of having the Skia Canvas update its transformation according to the scroll view, but as you can see in the following GIF, there is some pretty serious lag. I have tested this on actual hardware, so I know the problem is not from my screen mirroring/recording.

enter image description here

How can I solve this problem? Is there some alternative way to render very large, scrollable graphics in Skia in React Native that does not result in a crash or lag?

1

There are 1 answers

2
Nickolay Ninarski On

Instead of having a scroll view, you could use React Native Reanimated and React Native Gesture Handler.

The onScroll handler is running on the JS thread, which makes animations laggy.

If you wrap the Canvas with GestureDetector, and use the gesture to update a shared reanimated value, that is then passed to the group, the lagging should be fixed. When using shared values, instead of going from the handler through the UI thread and back to the UI component, they are directly passed to the reanimated component.

To animate the Group transform using an animated value, it should be wrapped using the createAnimatedComponent

const AnimatedGroup = Animated.createAnimatedComponent(Group)
const changeX = useSharedValue( 0 )
const pan = Gesture.Pan().onChange( ( e ) => {
    changeX.value = e.changeX
} ) 

...
<GestureDetector gesture={ pan }>
    <Canvas style={{ width:800, height:200 }}>
        <AnimatedGroup transform={[{translateX: changeX }]}>
            <GetCircles />
        </AnimatedGroup>
    </Canvas>
</GestureDetector>

Hope this helps!