React Native Skia - can it help Android out with shadows?

Quick experiment with Skia: build a simple card component and apply shadow, text and background gradient

ยท

8 min read

Skia is one of the most exciting new additions to the React Native community in recent months, bringing with it possibilities for advanced image manipulation, text animation, shading, masking and filtering. But can it also help us with some of the more common, day-to-day tasks that can sometimes be a source of discontent in react native, like simple box-shadows?

After watching some of the amazing videos released by William Candillon I wanted to have a quick go at using Skia for myself, but on a much smaller and simpler example. I don't often have a use-case for some of the advanced 2D graphics this library is capable of, but I wanted to have a play nevertheless, and explore the shadow features in particular. The lack of decent shadow support on Android within RN is something that has been an issue for me in the past, particularly when I wanted to animate a shadow (which proved disappointing on Android). I was hopeful that Skia could offer an alternative so I set myself a small task as an experiment.

Task:

  • Create a card component that should have:

    • Identical appearance and functionality on both Android and iOS.

    • Gradient background colour

    • (nice) Shadow

    • Text

    • Good accessibility for screen readers

Card component with shadow, text and gradient

Set up Expo app with React Native Skia

npx create-expo-app -t expo-template-blank-typescript skia-card cd skia-card

npx expo start

Initial Card element with gradient

// SkiaCard.tsx
import React from 'react';
import {
  Canvas,
  LinearGradient,
  vec,
  RoundedRect,
} from '@shopify/react-native-skia';
import {useWindowDimensions} from 'react-native';

export const SkiaCard = () => {
  const {width} = useWindowDimensions();
  const cardHeight = 200;
  const cardWidth = width * 0.9;
  return (
    <Canvas
      style={{
        width: cardWidth,
        height: cardHeight,
      }}
    >
      <RoundedRect r={10} x={0} y={0} width={cardWidth} height={cardHeight}>
        <LinearGradient
          start={vec(cardHeight / 2, 0)}
          end={vec(cardHeight / 2, cardHeight)}
          colors={['#6438C1', '#267BFC']}
        />
      </RoundedRect>
    </Canvas>
  );
};

To make the card rounded, we can simply change the Rect to a RoundedRect and give it a r prop eg: r={10}

Add shadow

In order to see the shadow we need to make our Canvas bigger than our card. This seems obvious now but it did catch me out at first.

I took the example from the Skia docs and adapted it. I liked how configurable the shadow was, and some nice subtle effects can be achieved. Here I have added a light shadow to the top of the card, and a slightly darker shadow below the card.

...
  <Canvas
      style={{
        width: cardWidth + canvasPadding, // canvasPadding allows room for shadow to show
        height: cardHeight + canvasPadding,
      }}
    >
      <RoundedRect
        r={10}
        x={canvasPadding / 2} 
        y={canvasPadding / 2} 
        width={cardWidth}
        height={cardHeight}
      >
        <LinearGradient
          start={vec(cardHeight / 2, 0)}
          end={vec(cardHeight / 2, cardHeight)}
          colors={[colors.darker, colors.lighter]}
        />

        <Shadow dx={-4} dy={-4} blur={10} color={colors.shadowLight} /> // top shadow
        <Shadow dx={0} dy={8} blur={10} color={colors.shadowDark} /> // bottom shadow
      </RoundedRect>
</Canvas>
...

Adding text to the card

One approach: Using Skia

Skia provides it's own Text element so my first thought was to use this. However, I soon realised that for my card's needs, this would not work well, and was probably not what Skia was intended for.

There are some examples of using Skia Text but the documentation at the time of writing was a little thin on this. I could see from the code examples, the use of a method getTextWidth on the Skia Text component, but I could not find any docs on this yet, and it was not clear to me if this would be useful if the text needed to be wrapped across multiple lines.

The main issues I found with adding the <Text> imported from Skia into the canvas:

  • It will be in-accessible to screen readers by default, as the Skia view renders essentially like a SVG and is not picked up by the screen reader as regular React Native elements would be. You could perhaps instead, wrap it in a view and provide your own screen reader labelling.

  • You cannot use the system fonts, but have to import font files directly.

  • As far as I can tell, Skia's <Text> element does not take the usual styles you would apply, like flex-wrap for example. So what happens when a user wants to have their phone's font size's larger? Firstly the font would not scale with the device's settings, and secondly, even if you added logic to detect the setting and rendered a larger size, without a flex layout this would leave the text overflowing the container.

  • Sizing the text and positioning it on the canvas would get complicated trying to get this to look good across phones and tablets.

My approach: Add Text outside of Skia

Instead, i decided to create a regular JSX element to contain my text, measure it, and place it on top of a Skia Card, essentially using the Card not as a parent, but as a background image. That way, we could keep the accessibility benefits of using the React Native Text element, and have the visual benefits of the Skia styling.

Having built a regular React Native View with Text, I created a parent component DisplayCard which would put the 2 elements together - the SkiaCard background, and the accessible React Native Text. To do this I use onLayout() to measure the size of my CardText, and pass this size as a prop to my SkiaCard. I then position the CardText absolutely, placing it on top of the SkiaCard.

I think this is a better solution to using Skia's Text element in this context, as it is more dynamic to content size changes, and the text is 100% accessible to screen readers without requiring additional labelling. However, it does raise some performance concerns due to the use of the onLayout() to measure the text, and setState to store the measurement. This would lead to extra renders and so might not be suitable for large lists for example.

import {StyleSheet, Text, View} from 'react-native';
import React, {FC} from 'react';
import {Ionicons} from '@expo/vector-icons';

interface CardTextProps {
  cardWidth: number;
}

const CardText: FC<CardTextProps> = ({cardWidth}) => {
  return (
    <View style={[styles.container, {width: cardWidth}]}>
      <View style={styles.iconContainer}>
        <Ionicons name="md-sunny" size={40} color="white" />
      </View>

      <View style={styles.textContainer}>
        <Text style={styles.title}>Card title text</Text>
        <Text style={styles.subtitle}>Card subtitle text</Text>
        <Text style={styles.subtitle}>Extra text here for example</Text>
      </View>
    </View>
  );
};
const DisplayCard = () => {
  const {width} = useWindowDimensions();
  const [textCardHeight, setTextCardHeight] = useState(0);

  const cardWidth = width * 0.9;
  const canvasPadding = 60;

  return (
    <View style={styles.wrap}>
      <SkiaCard
        cardHeight={textCardHeight}
        cardWidth={cardWidth}
        canvasPadding={canvasPadding}
      />
      <View
        onLayout={(e) => setTextCardHeight(e.nativeEvent.layout.height)}
        style={[styles.text, {top: canvasPadding / 2, left: canvasPadding / 2}]}
      >
        <CardText cardWidth={cardWidth} />
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  wrap: {
    position: 'relative',
  },
  text: {
    position: 'absolute',
  },
});

But you can do this without Skia, right?

I wanted to see what this basic example would look like the 'old-fashioned' way. Was there a reason to use Skia for a simple component like this, that doesn't really require the advanced 2D graphics Skia is intended for?

Here are the results, shown on both iOS and Android:

Android and iOS results

Linear gradient result: This was very easy to achieve using expo-linear-gradient and I would say the results are similar on iOS and Android, and not vastly different to the Skia version. Compared to Skia's linear gradient, the colors are slightly brighter without Skia perhaps (but this might be to do with my poor understanding of defining the gradient settings in the Skia version). I would say if linear gradient is all you need and you are able to use expo, then just go with expo-linear-gradient

Shadow result: Here is where the main issue was for me. On iOS, I would say the non-Skia shadow looks ok. But, I think the Skia shadow is better, and it is a lot more configurable. I could not get the subtle top shadow that was so easy in Skia. However, comparing this to the Android version we can see where the Skia version has a big advantage. The shadow (which on Android is only configurable using elevation and shadowColor) is nowhere near as good as the Skia version and looks completely different to the equivalent on iOS. If you are looking for consistent and highly configurable shadows across iOS and Android, then Skia is the clear winner here.

Also, if you intend to animate the card, I imagine this would have far better results with Skia, especially as it is designed to work well with react-native-reanimated... I intend to test this at a later stage. A cool use-case would be where you need to animate the shadow, perhaps to mimic a user picking up a card and moving it, where the shadow depth increases as the user lifts the card. This would have been near-impossible to achieve gracefully on Android before as far as I know.

Takeaways

  • Skia is very exciting, this example is very mundane compared to the more spectacular examples in the documentation and on youTube

  • Skia with Expo was very easy to set up

  • Simple background gradients are pretty good in React Native already, so if that's all you need then probably no need to bother with Skia just for this functionality

  • Shadows are rubbish in React Native on Android, and even on iOS are a bit limited. Skia's shadows are infinitely better. In Skia, shadows are highly configurable and are consistent across iOS and Android.

  • Skia ships with a <Text/> component but it is a bit fiddly to use, and as far as I can tell it is not accessible to screen readers so other aria labelling would need to be considered.

Gotcha's

  • Could not see the canvas initially when in a View withalignItems: 'center'. even with flex:1. I thought this was due to Skia not supporting flex layouts yet - meaning elements need to be absolutely positioned. But it turned out that I just needed to add the canvas height and width to the canvas style prop, not just flex:1.

  • Skia Text element requires a font to be passed in - I believe this needs to be a reference to a font file rather than a string/name of a system font. System fonts are not currently supported.

  • At the time of writing, when using the Skia Text element, hot-reloading gives up.

Resources

ย