How to make a sticky video on scroll like in Youtube app ?

React

React Native

portal

rn-native-portals

We might want to display a video in different places in our app and be able to move from one to another without losing the video in a similar way than it’s done in Youtube app. We see a lot of this feature in web articles with floating video and we might want to apply this principle in mobile app.

Hnet-image (1)

There are several methods to get to this result, you may think about some of them. Let’s start with a naive one : using boolean to hide and show the video.

First let’s see how it works with a static component like text. 

let’s take the next piece of code wich displays an article with a header and a big title :

export default function App() {
 return (
   <>
     <View style={styles.header}>
       <Text style={styles.H1}>SUPER HEADER</Text>
     </View>
  <ScrollView contentContainerStyle={styles.container} scrollEventThrottle={16} >
       <Text style={styles.H1}>BIG TITLE</Text>
       <Text>
         {'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n'}
       </Text>
     </ScrollView>
   </>
);
}


const styles = StyleSheet.create({
 header: {
   height: 100,
   backgroundColor: '#ffcc66',
   justifyContent: 'flex-end',
   alignItems: 'center',
},
 H1: {
   fontSize: 20,
   fontWeight: 'bold',
   paddingVertical: 20,
},
 container: {
   alignItems: 'center',
   justifyContent: 'center',
},
});

Capture d’écran 2021-03-19 à 13.31.41We want to display a title in the page and to keep it below the header when we pass it on scroll.

First, let’s display the title component in every place we want to display it, in the page and below the header.

     <View style={styles.header}>
       <Text style={styles.H1}>SUPER HEADER</Text>
     </View>
     <View style={styles.container}>
         <Text style={styles.H1}>Second Title</Text>
     </View>
     <ScrollView contentContainerStyle={styles.container} scrollEventThrottle={16} >
       <Text style={styles.H1}>BIG TITLE</Text>
       <Text>
         {'Lorem [...] laborum.\n'}
       </Text>
       <Text>
         {'Lorem [...] laborum.\n'}
       </Text>
       <View style={styles.container}>
         <Text style={styles.H1}>Second Title</Text>
       </View>
       <Text>
         {'Lorem [...] laborum.\n'}
       </Text>

Capture d’écran 2021-03-19 à 13.33.57We are going to give an onLayout prop to the component which is into the ScrollView to get the component’s position in the page. On scroll, when we are going to pass this position, we are going to change the display boolean value.

We define the onLayout method into a hook, useStickyComponent.

const useStickyComponent = () => {
const position = useRef();
 const titleRef = useRef(null);
 const onLayout = () => {
   if (titleRef.current) {
     titleRef.current.measure((h, pagey) => {
       position.current = pagey - 100 + h;
    });
  }
};
}

Using a reference instead of a state value to store the position avoids too many re-render in the page.

TitleRef is here the title component’s reference.

To get the component’s position, we use the measure method on the component reference.

We give the reference and onLayout method to the title component into the ScrollView as props.

     <View onLayout={onLayout} ref={titleRef}>
         <Text style={styles.H1}>Second Title</Text>
     </View>

In the state, we define a boolean to manage the displaying about the title component below the header.

const [isTitleSticky, setIsTitleSticky] = useState(false)
     {isTitleSticky && (
       <View style={styles.container}>
         <Text style={styles.H1}>Second Title</Text>
       </View>
     )}

In the hook, let’s create a onScroll method. This method changes the boolean isTitleSticky value in the state when passing the Title component position in the ScrollView.

 const onScroll = event => {
   if (position.current !== null) {
     setIsTitleSticky(event.nativeEvent.contentOffset.y >= position.current);
  }
};
<ScrollView contentContainerStyle={styles.container} scrollEventThrottle={16}
 onScroll={onScroll}>

TitleVideoNow we have moved a text component from the content to the header, let’s see how it goes with a video.

Replace the text component with a video and observe the result.

VideoBool

😱 It doesn’t seem to work well !! Our video starts and restarts every time it changes its place.

what’s the issue ?

Using boolean doesn’t render the same object in the different display places but a new instance of the component. We saw that above, we first duplicate the component where we wanted to display it. This does work well when working with static components such as text or images. Our eyes don’t see the difference between the two components.

 If we want to use the same method with a video, during the “change of position”, the video restart.

What is happening ?

Like said above, it’s two different videos we mount and unmount. We just have the impression that the video restarts from the beginning.

refresh-treeview

We want to keep the same video and move it from a position to another.

Portals to the rescue

It seems React Portals could resolve our issue. The idea is quite simple, to teleport the video from its position to another place in the component tree without re-render from a state change.

Problem is React Native doesn’t support Portals because Portals is part of ReactDOM, not React.

There is a custom React Native module which is a native implementation of the React Portals : rn-native-portals.

Rn-native-portals is able to move children components from a Parent View to another.

rn-native-portals installation

$ yarn add dylancom/rn-native-portals

# Using iOS

$ cd ios && pod install

 

The lib has two components :

  • PortalDestination
  • PortalOrigin

PortalDestination role is to get PortalOrigin children components and display them somewhere else in the component tree.

In our case, we wrap the video component with PortalOrigin, and we use a PortalDestination below the header.

We use a state flag to change the display video position on scroll.

We place a VideoPlaceHolder behind the video component in the scrollView to avoid scroll jump in the page when the video is moving.

useStickyComponent

const [playerDisplayLocation, setPlayerDisplayLocation] = useState('IN_APP');

 

onScroll

setPlayerDisplayLocation(topScreen >= position ? 'STICKY' : 'IN_APP');

 

Below the header

   <View style={styles.header}>
       <Text style={styles.H1}>SUPER HEADER</Text>
     </View>
     {playerDisplayLocation === 'STICKY' && (
       <View style={[{ width, height: 60 }]}>
         <PortalDestination name="STICKY" />
       </View>
     )}

 

In the ScrollView

      <View
         style={[{ width, height: 200 }, styles.videoPlaceholderInApp]}
         onLayout={onLayout}
         ref={playerRef}
       >
         <PortalOrigin destination={playerDisplayLocation === 'IN_APP' ? null : 'STICKY'}>
           <Video
             source={videoFile}
             style={
               playerDisplayLocation === 'IN_APP'
                 ? styles.backgroundVideo
                 : styles.backgroundVideoSticky
             }
             ref={ref => {
               this.player = ref;
             }}
             onBuffer={this.onBuffer}
             onError={this.videoError}
           />
         </PortalOrigin>
       </View>

PortalVideo-1

To get friendlier user experience we can use some animation form the Animated lib.

To get some more information about rn-native-portals see its github repo.